Compare commits

...

10 Commits

Author SHA1 Message Date
Levente Orban
7d75020cc1 fix: readme 2025-11-07 14:10:42 +01:00
Levente Orban
73c92b800a fix: outsorce user facing messages to json file 2025-11-07 14:01:47 +01:00
Levente Orban
1faa45e76b fix: add necesery envs 2025-11-07 13:55:59 +01:00
Levente Orban
8a45ad60fb fix: prettier linter fix 2025-11-07 13:37:51 +01:00
Levente Orban
258b822a27 feat: add federation to README.md 2025-11-06 23:32:59 +01:00
Levente Orban
c3f420df74 feat: federation instance list and api/healthz improvements 2025-11-06 22:57:27 +01:00
Levente Orban
9f74d58db1 feat: initialize federation service v1 2025-11-06 22:31:16 +01:00
Levente Orban
efe465d994 fix: validating capacity limit when users add guests 2025-11-03 09:05:19 +01:00
Levente Orban
1b79e6da58 fix: validating capacity limit when users add guests 2025-11-03 09:04:01 +01:00
Levente Orban
5ea620762a hotfix: prevent to use arrows in datepicker 2025-11-03 08:00:28 +01:00
22 changed files with 751 additions and 76 deletions

View File

@@ -4,10 +4,20 @@ POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# Application configuration
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database" DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
# Application configuration
APP_VERSION=latest APP_VERSION=latest
PORT=5173 PORT=5173
HOSTNAME=0.0.0.0 HOSTNAME=0.0.0.0
# Logger configuration
LOG_PRETTY=true
LOG_LEVEL=trace
# If you don't want to use the default home page you can turn off
# in this case the /discovery page remain the home of your site
PUBLIC_LANDING_INFO=true PUBLIC_LANDING_INFO=true
# Federation config
FEDERATION_INSTANCE=true

View File

@@ -61,6 +61,7 @@ jobs:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
build-args: | build-args: |
FEDERATION_INSTANCE=${{ vars.FEDERATION_INSTANCE }}
PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }} PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }}
LOG_PRETTY=${{ vars.LOG_PRETTY }} LOG_PRETTY=${{ vars.LOG_PRETTY }}
LOG_LEVEL=${{ vars.LOG_LEVEL }} LOG_LEVEL=${{ vars.LOG_LEVEL }}

View File

@@ -29,6 +29,7 @@ jobs:
- name: Build application - name: Build application
run: npm run build run: npm run build
env: env:
FEDERATION_INSTANCE: ${{ vars.FEDERATION_INSTANCE }}
PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }} PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }}
LOG_PRETTY: ${{ vars.LOG_PRETTY }} LOG_PRETTY: ${{ vars.LOG_PRETTY }}
LOG_LEVEL: ${{ vars.LOG_LEVEL }} LOG_LEVEL: ${{ vars.LOG_LEVEL }}

View File

@@ -7,6 +7,9 @@ RUN npm ci
ARG PUBLIC_LANDING_INFO ARG PUBLIC_LANDING_INFO
ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO
ARG FEDERATION_INSTANCE
ENV FEDERATION_INSTANCE=$FEDERATION_INSTANCE
ARG LOG_PRETTY ARG LOG_PRETTY
ENV LOG_PRETTY=$LOG_PRETTY ENV LOG_PRETTY=$LOG_PRETTY
@@ -29,7 +32,7 @@ ENV PORT 3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME "0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1 CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthz || exit 1
EXPOSE 3000 EXPOSE 3000
CMD [ "node", "build" ] CMD [ "node", "build" ]

View File

@@ -5,7 +5,7 @@ Events that thrive anywhere.
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable. Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.
<p align="center"> <p align="center">
<a href="https://cactoide.dalev.hu/" target="blank"> <a href="https://cactoide.org/" target="blank">
<picture> <picture>
<img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840"> <img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840">
</picture> </picture>
@@ -14,17 +14,25 @@ Like the cactus, great events bloom under any condition when managed with care.
#### What is it? #### What is it?
A mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances.
### ✨ Features ### ✨ Features
- **🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency. **🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency.
- **🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
- **🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place. **🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
- **📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
- **👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers. **🌐 Federation** - Connect with other Cactoide instances to discover events across the network. Share your public events and creating a decentralized event discovery network.
- **🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
- **✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go. **🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place.
**📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
**👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers.
**🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
**✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go.
### Quick Start ### Quick Start
@@ -55,6 +63,53 @@ Your app will be available at `http://localhost:5173`. You can use the Makefile
Use the `database/seed.sql` if you want to populate your database with dummy data. Use the `database/seed.sql` if you want to populate your database with dummy data.
### Federation
Cactoide supports federation, allowing multiple instances to share and discover public events across the network. This enables users to discover events from other Cactoide instances, creating a decentralized event discovery network.
<p align="center">
<img alt="Federation Example" src="./docs/federation_example.png" width="840">
</p>
#### How it works
Federation is managed through the `federation.config.js` file, which contains:
- **Instance name**: The display name for your instance when exposing events to the federation
- **Instance list**: An array of federated instance URLs. Add instance URLs here to discover events from other federated instances.
```javascript
const config = {
name: 'Cactoide Genesis',
instances: [{ url: 'js-meetups.seattle.io' }, { url: 'ai-events.seattle.com' }]
};
```
#### Opt-in
To enable federation on your instance, you need to:
1. **Set the environment variable**: Add `FEDERATION_INSTANCE=true` to your `.env` file. This enables the federation API endpoints on your instance.
2. **Configure your instance name**: Update the `name` field in your `federation.config.js` file to set your instance's display name.
Your instance will automatically expose:
- `/api/federation/events` - Returns all public events from your instance
- `/api/federation/info` - Returns your instance name and public events count
#### Adding your instance
To add your instance to the global federation list (so other instances can discover your events):
1. Fork the [Cactoide repository](https://github.com/polaroi8d/cactoide)
2. Add your instance URL to the `instances` array in [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js):
3. Open a pull request to the main repository
Once merged, your instance will appear in the federation network, and other instances will be able to discover and display your public events.
You can view all registered federated instances in the main repository: [`federation.config.js`](https://github.com/polaroi8d/cactoide/blob/main/federation.config.js) file.
### Options ### Options
#### 1. Landing page option #### 1. Landing page option
@@ -90,7 +145,6 @@ It isnt backed by a big company. Development depends on the support and gener
You can support in a few ways: You can support in a few ways:
- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben)
- Reach me directly: leventeorb[@]gmail.com - Reach me directly: leventeorb[@]gmail.com
If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community. If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community.

BIN
docs/federation_example.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

19
federation.config.js Normal file
View File

@@ -0,0 +1,19 @@
const config = {
name: 'Cactoide Genesis',
instances: [
{
url: 'cactoide.org'
},
{
url: 'cactoide.dalev.hu'
},
{
url: 'localhost:5174'
},
{
url: 'localhost:5175'
}
]
};
export default config;

View File

@@ -48,6 +48,13 @@
{t('navigation.create')} {t('navigation.create')}
</button> </button>
<button
on:click={() => goto('/instance')}
class={isActive('/instance') ? 'text-violet-400' : 'cursor-pointer'}
>
{t('navigation.instance')}
</button>
<button <button
on:click={() => goto('/event')} on:click={() => goto('/event')}
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'} class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}

View File

@@ -0,0 +1,146 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '$lib/logger';
import type { Event } from '$lib/types';
import config from '../../federation.config.js';
console.log(config.instances);
interface FederationConfig {
name: string;
instances: Array<{ url: string }>;
}
interface FederationEventsResponse {
events: Array<Event & { federation?: boolean }>;
count?: number;
}
/**
* Reads the federation config file
*/
async function readFederationConfig(): Promise<FederationConfig | null> {
try {
const configPath = join(process.cwd(), 'federation.config.js');
// Use dynamic import to load the config file as a module
// This is safer than eval and works with ES modules
const configModule = await import(configPath + '?t=' + Date.now());
const config = (configModule.default || configModule.config) as FederationConfig;
if (config && config.instances && Array.isArray(config.instances)) {
return config;
}
logger.warn('Invalid federation config structure');
return null;
} catch (error) {
// If dynamic import fails, try reading as text and parsing
try {
const configPath = join(process.cwd(), 'federation.config.js');
const configContent = readFileSync(configPath, 'utf-8');
// Try to extract JSON-like structure
const configMatch = configContent.match(/instances:\s*\[([\s\S]*?)\]/);
if (configMatch) {
// Simple parsing - extract URLs
const urlMatches = configContent.matchAll(/url:\s*['"]([^'"]+)['"]/g);
const instances = Array.from(urlMatches, (match) => ({ url: match[1] }));
if (instances.length > 0) {
return {
name: 'Federated Instances',
instances
};
}
}
} catch (fallbackError) {
logger.error({ error: fallbackError }, 'Error parsing federation.config.js as fallback');
}
logger.error({ error }, 'Error reading federation.config.js');
return null;
}
}
/**
* Fetches events from a single federated instance
*/
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
try {
// Ensure URL has protocol and append /api/federation/events
const apiUrl = `http://${instanceUrl}/api/federation/events`;
logger.debug({ apiUrl }, 'Fetching events from federated instance');
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch events from instance');
return [];
}
const data = (await response.json()) as FederationEventsResponse;
if (!data.events || !Array.isArray(data.events)) {
logger.warn({ apiUrl }, 'Invalid events response structure');
return [];
}
// Mark events as federated and add source URL
const federatedEvents: Event[] = data.events.map((event) => ({
...event,
federation: true,
federation_url: `http://${instanceUrl}`
}));
logger.info(
{ apiUrl, eventCount: federatedEvents.length },
'Successfully fetched federated events'
);
return federatedEvents;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching events from instance'
);
return [];
}
}
/**
* Fetches events from all configured federated instances
*/
export async function fetchAllFederatedEvents(): Promise<Event[]> {
const config = await readFederationConfig();
if (!config || !config.instances || config.instances.length === 0) {
logger.debug('No federation config or instances found');
return [];
}
logger.info(
{ instanceCount: config.instances.length },
'Fetching events from federated instances'
);
// Fetch from all instances in parallel
const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url));
const results = await Promise.all(fetchPromises);
// Flatten all events into a single array
const allFederatedEvents = results.flat();
logger.info({ totalEvents: allFederatedEvents.length }, 'Completed fetching federated events');
return allFederatedEvents;
}

View File

@@ -94,7 +94,8 @@
"home": "Home", "home": "Home",
"discover": "Scopri", "discover": "Scopri",
"create": "Crea", "create": "Crea",
"myEvents": "I Miei Eventi" "myEvents": "I Miei Eventi",
"instance": "Istanza"
}, },
"home": { "home": {
"title": "Cactoide - Il sito per gli RSVP", "title": "Cactoide - Il sito per gli RSVP",

View File

@@ -99,7 +99,8 @@
"home": "Home", "home": "Home",
"discover": "Discover", "discover": "Discover",
"create": "Create", "create": "Create",
"myEvents": "My Events" "myEvents": "My Events",
"instance": "Instance"
}, },
"home": { "home": {
"title": "Cactoide - The RSVP site", "title": "Cactoide - The RSVP site",
@@ -250,6 +251,21 @@
"noEventsFoundTitle": "No events found", "noEventsFoundTitle": "No events found",
"noEventsFoundDescription": "Try adjusting your search terms or browse all events" "noEventsFoundDescription": "Try adjusting your search terms or browse all events"
}, },
"instance": {
"name": "Name",
"url": "URL",
"events": "Events",
"healthStatus": "Health Status",
"responseTime": "Response Time",
"notAvailable": "N/A",
"healthStatusHealthy": "healthy",
"healthStatusUnhealthy": "unhealthy",
"healthStatusUnknown": "unknown",
"description": "These are the instances that are part of the github original federation list, if you want to add your instance to the list, please open a pull request to the",
"configFile": "federation.config.js",
"file": "file.",
"noInstances": "No federation instances configured."
},
"calendar": { "calendar": {
"addToCalendarTitle": "Add to Calendar", "addToCalendarTitle": "Add to Calendar",
"googleCalendarTitle": "Google Calendar", "googleCalendarTitle": "Google Calendar",

View File

@@ -17,6 +17,8 @@ export interface Event {
user_id: string; user_id: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
federation?: boolean; // Optional: true if event is from a federated instance
federation_url?: string; // Optional: URL of the federated instance this event came from
} }
export interface RSVP { export interface RSVP {

View File

@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { desc, eq } from 'drizzle-orm';
import { logger } from '$lib/logger';
import { FEDERATION_INSTANCE } from '$env/static/private';
export const GET: RequestHandler = async () => {
try {
if (!FEDERATION_INSTANCE) {
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
}
// Fetch all public and invite-only events ordered by creation date (newest first)
const publicEvents = await database
.select()
.from(events)
.where(eq(events.visibility, 'public'))
.orderBy(desc(events.createdAt));
// Transform events to include federation_event type
const transformedEvents = publicEvents.map((event) => ({
id: event.id,
name: event.name,
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
federation: true,
attendee_limit: event.attendeeLimit,
visibility: event.visibility,
user_id: event.userId,
created_at: event.createdAt?.toISOString() || '',
updated_at: event.updatedAt?.toISOString() || ''
}));
return json({
events: transformedEvents,
count: transformedEvents.length
});
} catch (error) {
logger.error({ error }, 'Error fetching events from API');
return json(
{
error: 'Failed to fetch events',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { eq, count } from 'drizzle-orm';
import { logger } from '$lib/logger';
import federationConfig from '../../../../../federation.config.js';
import { FEDERATION_INSTANCE } from '$env/static/private';
export const GET: RequestHandler = async () => {
try {
if (!FEDERATION_INSTANCE) {
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
}
// Count public events
const publicEventsCount = await database
.select({ count: count() })
.from(events)
.where(eq(events.visibility, 'public'));
const countValue = publicEventsCount[0]?.count || 0;
return json({
name: federationConfig.name,
publicEventsCount: countValue
});
} catch (error) {
logger.error({ error }, 'Error fetching federation info from API');
return json(
{
error: 'Failed to fetch federation info',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,28 @@
// src/routes/healthz/+server.ts
import { json } from '@sveltejs/kit';
import { database } from '$lib/database/db';
import { sql } from 'drizzle-orm';
export async function GET() {
const startTime = performance.now();
try {
await database.execute(sql`select 1`);
const responseTime = Math.round(performance.now() - startTime);
return json(
{ ok: true, responseTime, responseTimeUnit: 'ms' },
{ headers: { 'cache-control': 'no-store' } }
);
} catch (err) {
const responseTime = Math.round(performance.now() - startTime);
return json(
{
ok: false,
error: (err as Error)?.message,
message: 'Database unreachable.',
responseTime,
responseTimeUnit: 'ms'
},
{ status: 503, headers: { 'cache-control': 'no-store' } }
);
}
}

View File

@@ -3,10 +3,11 @@ import { desc, inArray } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { events } from '$lib/database/schema'; import { events } from '$lib/database/schema';
import { logger } from '$lib/logger'; import { logger } from '$lib/logger';
import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
try { try {
// Fetch all non-private events (public and invite-only) ordered by creation date (newest first) // Fetch all non-private events ordered by creation date (newest first)
const publicEvents = await database const publicEvents = await database
.select() .select()
.from(events) .from(events)
@@ -17,24 +18,36 @@ export const load: PageServerLoad = async () => {
const transformedEvents = publicEvents.map((event) => ({ const transformedEvents = publicEvents.map((event) => ({
id: event.id, id: event.id,
name: event.name, name: event.name,
date: event.date, // Already in 'YYYY-MM-DD' format date: event.date,
time: event.time, // Already in 'HH:MM:SS' format time: event.time,
location: event.location, location: event.location,
location_type: event.locationType, location_type: event.locationType,
location_url: event.locationUrl, location_url: event.locationUrl,
type: event.type, type: event.type,
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase attendee_limit: event.attendeeLimit,
visibility: event.visibility, visibility: event.visibility,
user_id: event.userId, // Note: schema uses camelCase user_id: event.userId,
created_at: event.createdAt?.toISOString(), created_at: event.createdAt?.toISOString(),
updated_at: event.updatedAt?.toISOString() updated_at: event.updatedAt?.toISOString(),
federation: false // Add false for local events
})); }));
// Fetch federated events from federation.config.js
let federatedEvents: typeof transformedEvents = [];
try {
federatedEvents = await fetchAllFederatedEvents();
} catch (error) {
logger.error({ error }, 'Error fetching federated events, continuing with local events only');
}
// Merge local and federated events
const allEvents = [...transformedEvents, ...federatedEvents];
return { return {
events: transformedEvents events: allEvents
}; };
} catch (error) { } catch (error) {
logger.error({ error }, 'Error loading public events'); logger.error({ error }, 'Error loading events');
// Return empty array on error to prevent page crash // Return empty array on error to prevent page crash
return { return {

View File

@@ -267,9 +267,15 @@
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each filteredEvents as event, i (i)} {#each filteredEvents as event, i (i)}
<div class="rounded-sm border border-slate-200 p-6 shadow-sm"> {@const isFederated = event.federation === true}
<div class="mb-4"> <div
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3> class="flex flex-col rounded-sm border border-slate-200 bg-slate-800/50
p-6 shadow-sm"
>
<div class="mb-4 flex-1">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-300">{event.name}</h3>
</div>
<div class="space-y-2 text-sm text-slate-500"> <div class="space-y-2 text-sm text-slate-500">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,6 +320,15 @@
<span>{event.location}</span> <span>{event.location}</span>
{/if} {/if}
</div> </div>
{#if isFederated && event.federation_url}
<div class="flex items-center space-x-2">
<span
class="rounded-sm border border-blue-500 px-2 py-1 text-xs
font-medium text-blue-500"
>
{event.federation_url}
</span>
</div>{:else}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type === class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
@@ -331,19 +346,32 @@
? 'border-teal-500 text-teal-500' ? 'border-teal-500 text-teal-500'
: 'border-amber-600 text-amber-600'}" : 'border-amber-600 text-amber-600'}"
> >
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')} {event.visibility === 'public'
? t('common.public')
: t('common.inviteOnly')}
</span> </span>
</div> </div>{/if}
</div> </div>
</div> </div>
<div class="flex"> <div class="mt-auto flex">
{#if isFederated && event.federation_url}
<a
href="{event.federation_url}/event/{event.id}"
target="_blank"
rel="noopener noreferrer"
class="flex-1 rounded-sm border-2 border-blue-500 bg-blue-400/20 px-4 py-2 text-center font-semibold duration-200 hover:bg-blue-400/70"
>
View
</a>
{:else}
<button <button
on:click={() => goto(`/event/${event.id}`)} on:click={() => goto(`/event/${event.id}`)}
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70" class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
> >
{t('discover.viewButton')} {t('discover.viewButton')}
</button> </button>
{/if}
</div> </div>
</div> </div>
{/each} {/each}

View File

@@ -104,14 +104,15 @@ export const actions: Actions = {
// Get current RSVPs // Get current RSVPs
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId)); const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
// Calculate total attendees (including guests) // Calculate remaining spots and ensure main attendee + guests fit
const totalAttendees = currentRSVPs.length + numberOfGuests; const newAttendeesCount = 1 + numberOfGuests;
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
// Check if event is full (for limited type events) // Check if event is full (for limited type events)
if (eventData.type === 'limited' && eventData.attendeeLimit) { if (eventData.type === 'limited' && eventData.attendeeLimit) {
if (totalAttendees > eventData.attendeeLimit) { if (newAttendeesCount > remainingSpots) {
return fail(400, { return fail(400, {
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.` error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} remain.`
}); });
} }
} }

View File

@@ -129,14 +129,15 @@ export const actions: Actions = {
// Get current RSVPs // Get current RSVPs
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId)); const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
// Calculate total attendees (including guests) // Calculate remaining spots and ensure main attendee + guests fit
const totalAttendees = currentRSVPs.length + numberOfGuests; const newAttendeesCount = 1 + numberOfGuests;
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
// Check if event is full (for limited type events) // Check if event is full (for limited type events)
if (eventData.type === 'limited' && eventData.attendeeLimit) { if (eventData.type === 'limited' && eventData.attendeeLimit) {
if (totalAttendees > eventData.attendeeLimit) { if (newAttendeesCount > remainingSpots) {
return fail(400, { return fail(400, {
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.` error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} remain.`
}); });
} }
} }

View File

@@ -1,16 +0,0 @@
// src/routes/healthz/+server.ts
import { json } from '@sveltejs/kit';
import { database } from '$lib/database/db';
import { sql } from 'drizzle-orm';
export async function GET() {
try {
await database.execute(sql`select 1`);
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
} catch (err) {
return json(
{ ok: false, error: (err as Error)?.message, message: 'Database unreachable.' },
{ status: 503, headers: { 'cache-control': 'no-store' } }
);
}
}

View File

@@ -0,0 +1,119 @@
import type { PageServerLoad } from './$types';
import { logger } from '$lib/logger';
import federationConfig from '../../../federation.config.js';
interface InstanceInfo {
name: string;
publicEventsCount: number;
}
interface HealthStatus {
ok: boolean;
responseTime?: number;
responseTimeUnit?: string;
error?: string;
}
interface InstanceData {
url: string;
name: string | null;
events: number | null;
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
responseTime: number | null;
error?: string;
}
async function fetchInstanceInfo(instanceUrl: string): Promise<InstanceInfo | null> {
try {
const apiUrl = `http://${instanceUrl}/api/federation/info`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch instance info');
return null;
}
const data = (await response.json()) as InstanceInfo;
return data;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching instance info'
);
return null;
}
}
async function fetchHealthStatus(instanceUrl: string): Promise<HealthStatus | null> {
try {
const apiUrl = `http://${instanceUrl}/api/healthz`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
Accept: 'application/json'
},
signal: AbortSignal.timeout(10000) // 10 second timeout
});
if (!response.ok) {
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch health status');
return { ok: false, error: `HTTP ${response.status}` };
}
const data = (await response.json()) as HealthStatus;
return data;
} catch (error) {
logger.error(
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
'Error fetching health status'
);
return { ok: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
}
export const load: PageServerLoad = async () => {
try {
const instances = federationConfig.instances || [];
// Fetch data from all instances in parallel
const instanceDataPromises = instances.map(async (instance): Promise<InstanceData> => {
const [info, health] = await Promise.all([
fetchInstanceInfo(instance.url),
fetchHealthStatus(instance.url)
]);
const responseTime = health?.responseTime ?? null;
const healthStatus: 'healthy' | 'unhealthy' | 'unknown' = health?.ok
? 'healthy'
: health === null
? 'unknown'
: 'unhealthy';
return {
url: instance.url,
name: info?.name ?? null,
events: info?.publicEventsCount ?? null,
healthStatus,
responseTime,
error: health?.error
};
});
const instanceData = await Promise.all(instanceDataPromises);
return {
instances: instanceData
};
} catch (error) {
logger.error({ error }, 'Error loading instance data');
return {
instances: []
};
}
};

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { t } from '$lib/i18n/i18n.js';
interface InstanceData {
url: string;
name: string | null;
events: number | null;
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
responseTime: number | null;
error?: string;
}
type InstancePageData = {
instances: InstanceData[];
};
export let data: InstancePageData;
function getStatusColor(responseTime: number | null): string {
if (responseTime === null) return 'bg-gray-400';
if (responseTime < 10) return 'bg-green-500';
if (responseTime <= 30) return 'bg-yellow-500';
return 'bg-red-500';
}
function formatResponseTime(responseTime: number | null): string {
if (responseTime === null) return t('instance.notAvailable');
return `${responseTime} ms`;
}
function getHealthStatusText(status: 'healthy' | 'unhealthy' | 'unknown'): string {
switch (status) {
case 'healthy':
return t('instance.healthStatusHealthy');
case 'unhealthy':
return t('instance.healthStatusUnhealthy');
case 'unknown':
return t('instance.healthStatusUnknown');
}
}
</script>
<div class="container mx-auto px-4 py-16 text-white">
<div class="overflow-x-auto">
<table class="min-w-full rounded-lg border border-slate-600 bg-slate-800/50 shadow-sm">
<thead class="bg-slate-800">
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
{t('instance.name')}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
{t('instance.url')}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
{t('instance.events')}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
{t('instance.healthStatus')}
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
>
{t('instance.responseTime')}
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-700">
{#each data.instances as instance, i (i)}
<tr class="hover:bg-slate-700/50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm font-medium text-slate-300">
{instance.name || t('instance.notAvailable')}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<a
href="http://{instance.url}"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-slate-400 hover:text-violet-300/80"
>
{instance.url}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="text-sm text-slate-300">
{instance.events !== null ? instance.events : t('instance.notAvailable')}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
instance.responseTime
)}"
title={getHealthStatusText(instance.healthStatus)}
></span>
<span class="text-sm text-slate-300 capitalize">
{getHealthStatusText(instance.healthStatus)}
</span>
{#if instance.error}
<span class="ml-2 text-xs text-slate-500">({instance.error})</span>
{/if}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<span
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
instance.responseTime
)}"
></span>
<span class="text-sm text-slate-300">
{formatResponseTime(instance.responseTime)}
</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
<p class="py-8 text-center text-slate-400">
{t('instance.description')}
<a
href="https://github.com/cactoide/cactoide/blob/main/federation.config.js"
class="text-violet-300/80">{t('instance.configFile')}</a
>
{t('instance.file')}
</p>
{#if data.instances.length === 0}
<div class="py-8 text-center text-slate-500">{t('instance.noInstances')}</div>
{/if}
</div>
</div>
<style>
/* Additional styles if needed */
</style>