Compare commits

...

16 Commits

Author SHA1 Message Date
Levente Orban
87d2275373 feat: add llms.txt to repository - i don't know this is a good approach :D 2025-11-08 20:28:37 +01:00
Levente Orban
6e314af82b Update DATABASE_URL with default value 2025-11-08 11:26:04 +01:00
Levente Orban
2cb74bccd0 fix: custom log level error 2025-11-08 11:25:37 +01:00
Levente Orban
0ecaf54227 fix: custom log level error 2025-11-08 11:24:34 +01:00
Lajos Papp
4179bca981 Update DATABASE_URL with default value 2025-11-08 10:31:22 +01:00
Levente Orban
406a669a98 feat: federation service implementation v1 2025-11-07 14:20:12 +01:00
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
5ea620762a hotfix: prevent to use arrows in datepicker 2025-11-03 08:00:28 +01:00
Levente Orban
e5be4b5589 hotfix: prpevent to use arrows in datepicker 2025-11-02 20:44:58 +01:00
26 changed files with 921 additions and 76 deletions

View File

@@ -4,10 +4,20 @@ POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432
# Application configuration
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
# Application configuration
APP_VERSION=latest
PORT=5173
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
# Federation config
FEDERATION_INSTANCE=true

View File

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

View File

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

View File

@@ -7,6 +7,9 @@ RUN npm ci
ARG PUBLIC_LANDING_INFO
ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO
ARG FEDERATION_INSTANCE
ENV FEDERATION_INSTANCE=$FEDERATION_INSTANCE
ARG LOG_PRETTY
ENV LOG_PRETTY=$LOG_PRETTY
@@ -29,7 +32,7 @@ ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
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
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.
<p align="center">
<a href="https://cactoide.dalev.hu/" target="blank">
<a href="https://cactoide.org/" target="blank">
<picture>
<img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840">
</picture>
@@ -14,17 +14,25 @@ Like the cactus, great events bloom under any condition when managed with care.
#### 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
- **🎯 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.
- **📅 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.
**🎯 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.
**🌐 Federation** - Connect with other Cactoide instances to discover events across the network. Share your public events and creating a decentralized event discovery network.
**🔍 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
@@ -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.
### 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
#### 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:
- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben)
- 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.

View File

@@ -34,7 +34,7 @@ services:
ports:
- '${PORT:-5111}:3000'
environment:
DATABASE_URL: ${DATABASE_URL}
DATABASE_URL: ${DATABASE_URL:-postgres://cactoide:cactoide_password@postgres:5432/cactoide_database}
PORT: 3000
HOSTNAME: ${HOSTNAME:-0.0.0.0}
depends_on:

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;

7
package-lock.json generated
View File

@@ -24,12 +24,8 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
<<<<<<< HEAD
"drizzle-kit": "^0.31.5",
=======
"@types/node": "^24.9.1",
"drizzle-kit": "^0.31.4",
>>>>>>> 222c2ee (feat: add pino logger for serverside)
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -2108,8 +2104,6 @@
"dev": true,
"license": "MIT"
},
<<<<<<< HEAD
=======
"node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
@@ -2121,7 +2115,6 @@
"undici-types": "~7.16.0"
}
},
>>>>>>> 222c2ee (feat: add pino logger for serverside)
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",

View File

@@ -48,6 +48,13 @@
{t('navigation.create')}
</button>
<button
on:click={() => goto('/instance')}
class={isActive('/instance') ? 'text-violet-400' : 'cursor-pointer'}
>
{t('navigation.instance')}
</button>
<button
on:click={() => goto('/event')}
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",
"discover": "Scopri",
"create": "Crea",
"myEvents": "I Miei Eventi"
"myEvents": "I Miei Eventi",
"instance": "Istanza"
},
"home": {
"title": "Cactoide - Il sito per gli RSVP",

View File

@@ -99,7 +99,8 @@
"home": "Home",
"discover": "Discover",
"create": "Create",
"myEvents": "My Events"
"myEvents": "My Events",
"instance": "Instance"
},
"home": {
"title": "Cactoide - The RSVP site",
@@ -250,6 +251,21 @@
"noEventsFoundTitle": "No events found",
"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": {
"addToCalendarTitle": "Add to Calendar",
"googleCalendarTitle": "Google Calendar",

View File

@@ -1,6 +1,16 @@
import pino from 'pino';
import { LOG_PRETTY, LOG_LEVEL } from '$env/static/private';
try {
if (LOG_PRETTY && LOG_LEVEL) {
console.debug(
`Initializing logger with pretty logs: LOG_PRETTY: ${LOG_PRETTY} and LOG_LEVEL: ${LOG_LEVEL}`
);
}
} catch (error) {
console.error('Error initializing logger', error);
}
const USE_PRETTY_LOGS = LOG_PRETTY === 'true';
const transport = USE_PRETTY_LOGS
@@ -10,6 +20,14 @@ const transport = USE_PRETTY_LOGS
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
},
customLevels: {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60
}
}
: undefined;

View File

@@ -17,6 +17,8 @@ export interface Event {
user_id: string;
created_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 {

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

@@ -139,6 +139,11 @@
bind:value={eventData.date}
min={today}
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
on:keydown={(e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
}
}}
required
/>
{#if errors.date}

View File

@@ -3,10 +3,11 @@ import { desc, inArray } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { events } from '$lib/database/schema';
import { logger } from '$lib/logger';
import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents';
export const load: PageServerLoad = async () => {
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
.select()
.from(events)
@@ -17,24 +18,36 @@ export const load: PageServerLoad = async () => {
const transformedEvents = publicEvents.map((event) => ({
id: event.id,
name: event.name,
date: event.date, // Already in 'YYYY-MM-DD' format
time: event.time, // Already in 'HH:MM:SS' format
date: event.date,
time: event.time,
location: event.location,
location_type: event.locationType,
location_url: event.locationUrl,
type: event.type,
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
attendee_limit: event.attendeeLimit,
visibility: event.visibility,
user_id: event.userId, // Note: schema uses camelCase
user_id: event.userId,
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 {
events: transformedEvents
events: allEvents
};
} 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 {

View File

@@ -267,9 +267,15 @@
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each filteredEvents as event, i (i)}
<div class="rounded-sm border border-slate-200 p-6 shadow-sm">
<div class="mb-4">
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
{@const isFederated = event.federation === true}
<div
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="flex items-center space-x-2">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -314,6 +320,15 @@
<span>{event.location}</span>
{/if}
</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">
<span
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-amber-600 text-amber-600'}"
>
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
{event.visibility === 'public'
? t('common.public')
: t('common.inviteOnly')}
</span>
</div>
</div>{/if}
</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
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"
>
{t('discover.viewButton')}
</button>
{/if}
</div>
</div>
{/each}

View File

@@ -162,6 +162,11 @@
bind:value={eventData.date}
min={today}
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
on:keydown={(e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault();
}
}}
required
/>
{#if errors.date}

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>

151
static/llms.txt Normal file
View File

@@ -0,0 +1,151 @@
# Cactoide
> A federated mobile-first event RSVP platform built with SvelteKit. Cactoide is an open-source alternative to big tech event platforms like Meetup.com, Eventbrite, and Luma. It allows users to create events, share unique URLs, and collect RSVPs without any registration required. Features include built-in federation for decentralized event discovery across multiple instances, iCal integration, smart capacity limits, and a no-signup approach. The platform uses PostgreSQL with Drizzle ORM, implements federation via configurable instance lists, and supports multiple languages through a simple i18n system.
Cactoide is an open-source event management platform licensed under AGPL-3.0, designed as a privacy-focused alternative to centralized event platforms. Unlike Meetup.com, Eventbrite, Luma, and other big tech solutions, Cactoide requires no user accounts, collects minimal data, and operates on a federated model that gives users control over their events and data. Events can be public, private, or invite-only. The platform supports both limited and unlimited RSVP capacity.
## Architecture
The project is built with:
- **Frontend**: SvelteKit 5 with TypeScript, Tailwind CSS
- **Backend**: SvelteKit server routes and API endpoints
- **Database**: PostgreSQL with Drizzle ORM
- **Deployment**: Docker and Docker Compose support
Key architectural decisions:
- File-based routing (SvelteKit conventions)
- Server-side rendering for all pages
- Cookie-based user identification (no authentication system)
- Federation via HTTP API endpoints between instances
## API Endpoints
- [Health Check](/api/healthz): Returns instance health status and response time in milliseconds
- [Federation Events](/api/federation/events): Returns all public events from the instance (requires FEDERATION_INSTANCE env variable)
- [Federation Info](/api/federation/info): Returns instance name and public events count (requires FEDERATION_INSTANCE env variable)
## Database Schema
The database uses three main tables:
- **events**: Stores event information (id, name, date, time, location, type, visibility, attendee_limit, user_id)
- **rsvps**: Stores RSVP responses (id, event_id, name, user_id, created_at)
- **invite_tokens**: Stores invite tokens for invite-only events (id, event_id, token, expires_at)
Event visibility can be: public, private, or invite-only. Event types can be: limited or unlimited.
## Federation
Federation allows multiple Cactoide instances to share and discover public events. Configuration is managed through `federation.config.js` which contains:
- Instance name (display name for the instance)
- Instance list (array of federated instance URLs to discover events from)
To enable federation on an instance:
1. Set `FEDERATION_INSTANCE=true` environment variable
2. Configure instance name in `federation.config.js`
3. Add other instance URLs to the instances array to discover their events
Federated instances are displayed at `/instance` with health status, response times, and event counts.
## Routes
- `/` - Landing page (can be disabled with PUBLIC_LANDING_INFO=false)
- `/create` - Event creation form
- `/discover` - Public events discovery page with search and filters
- `/event/[id]` - Individual event page with RSVP functionality
- `/instance` - Federation instances status page
## Code Structure
### src/routes/
SvelteKit file-based routing system. Each folder represents a route, with `+page.svelte` for UI and `+page.server.ts` for server-side data loading.
- [src/routes/](https://github.com/polaroi8d/cactoide/tree/main/src/routes): Main routing directory
- `+layout.svelte` / `+layout.server.ts`: Root layout component and server-side data loading for all pages
- `+page.svelte` / `+page.server.ts`: Landing/home page
- `+error.svelte`: Global error page component
- [src/routes/create/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/create): Event creation form page
- [src/routes/discover/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/discover): Public events discovery page with search and filtering
- [src/routes/event/[id]/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/event/[id]): Dynamic route for individual event pages
- `edit/`: Event editing functionality
- `invite/[token]/`: Invite-only event access via token
- [src/routes/instance/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/instance): Federation instances status monitoring page
- [src/routes/api/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/api): API endpoints
- `healthz/+server.ts`: Health check endpoint with response time
- `federation/events/+server.ts`: Returns public events for federation (requires FEDERATION_INSTANCE)
- `federation/info/+server.ts`: Returns instance name and public events count (requires FEDERATION_INSTANCE)
### src/lib/
Shared library code accessible via `$lib` alias throughout the application.
- [src/lib/](https://github.com/polaroi8d/cactoide/tree/main/src/lib): Core library directory
- [src/lib/components/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/components): Reusable Svelte components
- `Navbar.svelte`: Main navigation bar component
- `CalendarModal.svelte`: Calendar integration modal for iCal downloads
- [src/lib/database/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/database): Database layer
- `schema.ts`: Drizzle ORM schema definitions (events, rsvps, invite_tokens tables)
- `db.ts`: Database connection and Drizzle instance setup
- `healthCheck.ts`: Database health check utilities
- [src/lib/i18n/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/i18n): Internationalization
- `i18n.ts`: i18n initialization and translation function
- `messages.json`: Default English translations
- `it.json`: Italian translation file (example of additional language)
- `types.ts`: TypeScript type definitions (Event, RSVP, EventType, EventVisibility, LocationType, etc.)
- `dateHelpers.ts`: Date and time formatting utilities, time range filtering for events
- `calendarHelpers.ts`: iCal file generation and calendar service link creation (Google Calendar, Outlook, etc.)
- `fetchFederatedEvents.ts`: Federation logic for fetching events from other Cactoide instances
- `inviteTokenHelpers.ts`: Invite token generation and expiration calculation utilities
- `generateUserId.ts`: User ID generation for cookie-based user identification
- `logger.ts`: Pino-based logging configuration
- `index.ts`: Library entry point (currently placeholder)
### src/
Root source directory containing application configuration and entry points.
- `app.html`: HTML template for the application
- `app.css`: Global CSS styles and Tailwind imports
- `app.d.ts`: TypeScript type declarations
- `hooks.server.ts`: SvelteKit server hooks for request handling, user ID cookie management, and error handling
### Root Level Directories
- [database/](https://github.com/polaroi8d/cactoide/tree/main/database): Database initialization and migration files
- `init.sql`: Database schema initialization script (creates tables, enums, indexes)
- `seed.sql`: Sample data for development and testing
- [database/migrations/](https://github.com/polaroi8d/cactoide/tree/main/database/migrations): SQL migration files for schema changes
- [static/](https://github.com/polaroi8d/cactoide/tree/main/static): Static assets served directly by the web server
- `favicon.ico`: Site favicon
- `robots.txt`: Search engine crawler directives
- `llms.txt`: This file - LLM-friendly project documentation
- [scripts/](https://github.com/polaroi8d/cactoide/tree/main/scripts): Utility scripts
- `i18n-check.sh`: Translation file validation script
- [docs/](https://github.com/polaroi8d/cactoide/tree/main/docs): Documentation assets
- `federation_example.png`: Screenshot example for federation documentation
### Configuration Files
- `federation.config.js`: Federation configuration (instance name and list of federated instance URLs)
- `package.json`: Node.js dependencies and scripts
- `svelte.config.js`: SvelteKit configuration (adapter, preprocessors)
- `vite.config.ts`: Vite build tool configuration
- `tailwind.config.js`: Tailwind CSS configuration
- `tsconfig.json`: TypeScript compiler configuration
- `eslint.config.js`: ESLint linting rules
- `docker-compose.yml`: Docker Compose setup for local development with PostgreSQL
- `Dockerfile`: Production Docker image configuration
- `Makefile`: Development command shortcuts (db-only, i18n validation, etc.)
## Configuration
Key environment variables:
- `DATABASE_URL`: PostgreSQL connection string
- `FEDERATION_INSTANCE`: Set to `true` to enable federation API endpoints
- `PUBLIC_LANDING_INFO`: Set to `false` to disable landing page and redirect to `/discover`
## Optional
- [docker-compose.yml](https://github.com/polaroi8d/cactoide/blob/main/docker-compose.yml): Docker Compose configuration for local development
- [Dockerfile](https://github.com/polaroi8d/cactoide/blob/main/Dockerfile): Production Docker image configuration
- [database/init.sql](https://github.com/polaroi8d/cactoide/blob/main/database/init.sql): Database initialization SQL
- [database/migrations/](https://github.com/polaroi8d/cactoide/tree/main/database/migrations): Database migration files
- [Makefile](https://github.com/polaroi8d/cactoide/blob/main/Makefile): Development commands and shortcuts