Compare commits

...

18 Commits

Author SHA1 Message Date
Levente Orban
d18afc43da fix: add permissions to github workflow 2025-12-08 09:41:43 +01:00
Levente Orban
662476f820 fix: add permissions to github workflow 2025-12-08 08:43:00 +01:00
Levente Orban
7ebf95bb16 feat: add github to landing pagE 2025-11-11 17:43:41 +01:00
Levente Orban
0491ec4c4b fix: change the readme.md gif 2025-11-11 16:24:03 +01:00
Levente Orban
fcdef065d7 fix: missing federation.config error 2025-11-10 11:46:50 +01:00
Levente Orban
a1fa879f36 fix: improve the federation.config 2025-11-10 11:46:02 +01:00
Levente Orban
b723aac180 fix: federation config refactor 2025-11-10 10:14:44 +01:00
Levente Orban
277ad3ff14 feat: add db-seed to makefile and merge migrations 2025-11-10 10:01:23 +01:00
Levente Orban
52d48e4839 feat: add db-seed to makefile and merge migrations 2025-11-10 09:59:44 +01:00
Levente Orban
5b7178bec1 fix: remove unused federation config 2025-11-08 22:23:19 +01:00
Levente Orban
7531af9d29 fix: the i18n checker script 2025-11-08 22:20:12 +01:00
Levente Orban
94152b6740 fix: remove unused federation config 2025-11-08 22:19:46 +01:00
Levente Orban
6155cc44da fix: the i18n checker script 2025-11-08 22:14:00 +01:00
Levente Orban
719cd23350 feat: add llms.txt to repository - i don't know this is a good approach :D 2025-11-08 20:42:24 +01:00
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
Lajos Papp
4179bca981 Update DATABASE_URL with default value 2025-11-08 10:31:22 +01:00
20 changed files with 417 additions and 390 deletions

View File

@@ -10,4 +10,14 @@ 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=false

View File

@@ -1,5 +1,7 @@
# .github/workflows/docker-build-and-push.yml
name: build & push the images name: build & push the images
permissions:
contents: read
pull-requests: write
on: on:
push: push:

View File

@@ -1,4 +1,7 @@
name: test & build name: test & build
permissions:
contents: read
pull-requests: write
on: on:
push: push:

View File

@@ -1,4 +1,4 @@
.PHONY: help build up down db-only logs db-clean prune i18n lint format migrate-up migrate-down .PHONY: help build up down db-only db-seed logs db-clean prune i18n lint format migrate-up migrate-down
# Database connection variables # Database connection variables
DB_HOST ?= localhost DB_HOST ?= localhost
@@ -19,10 +19,11 @@ help:
@echo " up - Start all services" @echo " up - Start all services"
@echo " down - Stop all services" @echo " down - Stop all services"
@echo " db-only - Start only the database" @echo " db-only - Start only the database"
@echo " db-seed - Seed the database with sample data"
@echo " logs - Show logs from all services" @echo " logs - Show logs from all services"
@echo " db-clean - Clean up all Docker resources" @echo " db-clean - Clean up all Docker resources"
@echo " prune - Clean up everything (containers, images, volumes)" @echo " prune - Clean up everything (containers, images, volumes)"
@echo " i18n - Validate translation files" @echo " i18n - List missing keys in translation file (use FILE=path/to/file.json)"
@echo " lint - Lint the project" @echo " lint - Lint the project"
@echo " format - Format the project" @echo " format - Format the project"
@echo " migrate-up - Apply invite-only events migration" @echo " migrate-up - Apply invite-only events migration"
@@ -73,6 +74,17 @@ db-only:
@echo "Starting only the database..." @echo "Starting only the database..."
docker compose up -d postgres docker compose up -d postgres
# Seed the database with sample data
db-seed:
@echo "Seeding database with sample data..."
@if [ -f "database/seed.sql" ]; then \
psql "$(DB_URL)" -f database/seed.sql && \
echo "Database seeded successfully!"; \
else \
echo "Seed file not found: database/seed.sql"; \
exit 1; \
fi
# Show logs from all services # Show logs from all services
logs: logs:
@echo "Showing logs from all services..." @echo "Showing logs from all services..."
@@ -94,8 +106,10 @@ format:
@echo "Formatting the project..." @echo "Formatting the project..."
npm run format npm run format
#TODO: not working yet # List missing keys in a translation file
i18n: i18n:
@echo "Validating translation files..." @if [ -z "$(FILE)" ]; then \
@if [ -n "$(FILE)" ]; then \ echo "Error: FILE variable is required. Example: make i18n FILE=src/lib/i18n/it.json"; \
./scripts/i18n-check.sh $(FILE); \ exit 1; \
fi
@./scripts/i18n-check.sh --missing-only $(FILE)

View File

@@ -7,7 +7,7 @@ Like the cactus, great events bloom under any condition when managed with care.
<p align="center"> <p align="center">
<a href="https://cactoide.org/" 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/a7f7a732-1279-486e-808c-1d2348c68780" width="840">
</picture> </picture>
</a> </a>
</p> </p>
@@ -59,6 +59,17 @@ make db-only
npm run dev -- --open npm run dev -- --open
``` ```
#### Build the image in local
```
docker build \
--build-arg LOG_PRETTY=${LOG_PRETTY:-true} \
--build-arg LOG_LEVEL=${LOG_LEVEL:-trace} \
--build-arg PUBLIC_LANDING_INFO=${PUBLIC_LANDING_INFO:-true} \
--build-arg FEDERATION_INSTANCE=${FEDERATION_INSTANCE:-true} \
-t cactoide-example .
```
Your app will be available at `http://localhost:5173`. You can use the Makefile commands to run the application or the database, eg.: `make db-only`. Your app will be available at `http://localhost:5173`. You can use the Makefile commands to run the application or the database, eg.: `make db-only`.
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.
@@ -103,12 +114,12 @@ Your instance will automatically expose:
To add your instance to the global federation list (so other instances can discover your events): 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) 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): 2. Add your instance URL to the `instances` array in `federation.config.js`:
3. Open a pull request to the main repository 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. 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. You can view all registered federated instances in the main repository: `federation.config.js` file.
### Options ### Options

View File

@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS events (
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')), type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
attendee_limit INTEGER CHECK (attendee_limit > 0), attendee_limit INTEGER CHECK (attendee_limit > 0),
user_id VARCHAR(100) NOT NULL, user_id VARCHAR(100) NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','private')), visibility VARCHAR(20) NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','private', 'invite-only')),
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
@@ -34,6 +34,15 @@ CREATE TABLE IF NOT EXISTS rsvps (
updated_at TIMESTAMPTZ DEFAULT NOW() updated_at TIMESTAMPTZ DEFAULT NOW()
); );
-- Invite tokens
CREATE TABLE IF NOT EXISTS invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(8) NOT NULL REFERENCES events(id) ON DELETE CASCADE,
token VARCHAR(32) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ======================================= -- =======================================
-- Indexes -- Indexes
-- ======================================= -- =======================================
@@ -42,5 +51,9 @@ CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type); CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id); CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id); CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);
COMMIT; COMMIT;

View File

@@ -1,25 +0,0 @@
-- Migration: Add invite-only events feature
-- Created: 2024-12-20
-- Description: Adds invite-only visibility option and invite tokens table
-- Add 'invite-only' to the visibility enum
ALTER TABLE events
DROP CONSTRAINT IF EXISTS events_visibility_check;
ALTER TABLE events
ADD CONSTRAINT events_visibility_check
CHECK (visibility IN ('public', 'private', 'invite-only'));
-- Create invite_tokens table
CREATE TABLE IF NOT EXISTS invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id VARCHAR(8) NOT NULL REFERENCES events(id) ON DELETE CASCADE,
token VARCHAR(32) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for invite_tokens table
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);

View File

@@ -1,17 +0,0 @@
-- Rollback Migration: Remove invite-only events feature
-- Created: 2024-12-20
-- Description: Removes invite-only visibility option and invite tokens table
-- Drop invite_tokens table and its indexes
DROP INDEX IF EXISTS idx_invite_tokens_expires_at;
DROP INDEX IF EXISTS idx_invite_tokens_token;
DROP INDEX IF EXISTS idx_invite_tokens_event_id;
DROP TABLE IF EXISTS invite_tokens;
-- Revert visibility enum to original values
ALTER TABLE events
DROP CONSTRAINT IF EXISTS events_visibility_check;
ALTER TABLE events
ADD CONSTRAINT events_visibility_check
CHECK (visibility IN ('public', 'private'));

View File

@@ -34,9 +34,13 @@ services:
ports: ports:
- '${PORT:-5111}:3000' - '${PORT:-5111}:3000'
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: ${DATABASE_URL:-postgres://cactoide:cactoide_password@postgres:5432/cactoide_database}
PORT: 3000 PORT: 3000
HOSTNAME: ${HOSTNAME:-0.0.0.0} HOSTNAME: ${HOSTNAME:-0.0.0.0}
LOG_PRETTY: ${LOG_PRETTY:-true}
LOG_LEVEL: ${LOG_LEVEL:-trace}
PUBLIC_LANDING_INFO: ${PUBLIC_LANDING_INFO:-true}
FEDERATION_INSTANCE: ${FEDERATION_INSTANCE:-true}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View File

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

View File

@@ -1,191 +1,89 @@
#!/bin/bash #!/usr/bin/env bash
# Find keys present in messages.json but missing in a translation file.
# Translation validation script set -euo pipefail
# Compares a translation file against the source messages.json to find missing keys
set -e SOURCE_DEFAULT="src/lib/i18n/messages.json"
# Colors for output usage() {
RED='\033[0;31m' echo "Usage: $0 [--missing-only] <translation.json> [source_messages.json]"
GREEN='\033[0;32m' echo "Compares <translation.json> against messages.json and prints missing keys."
YELLOW='\033[1;33m' exit 1
NC='\033[0m' # No Color
# Default paths
SOURCE_FILE="src/lib/i18n/messages.json"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Function to show usage
show_usage() {
echo "Usage: $0 [LANGUAGE_FILE]"
echo ""
echo "Validates a translation file against the source messages.json"
echo ""
echo "Arguments:"
echo " LANGUAGE_FILE Path to the translation file to validate (e.g., src/lib/i18n/it.json)"
echo ""
echo "Examples:"
echo " $0 src/lib/i18n/it.json"
echo " $0 src/lib/i18n/fr.json"
echo ""
echo "If no file is provided, it will check all .json files in src/lib/i18n/ except messages.json"
}
# Function to get all keys from a JSON file recursively
get_keys() {
local file="$1"
local prefix="$2"
# Use jq to extract all keys recursively
jq -r 'paths(scalars) as $p | $p | join(".")' "$file" | while read -r key; do
if [ -n "$prefix" ]; then
echo "${prefix}.${key}"
else
echo "$key"
fi
done
}
# Function to validate a single translation file
validate_file() {
local translation_file="$1"
local source_file="$2"
echo -e "${YELLOW}Validating: $translation_file${NC}"
echo "----------------------------------------"
# Check if files exist
if [ ! -f "$source_file" ]; then
echo -e "${RED}Error: Source file $source_file not found${NC}"
return 1
fi
if [ ! -f "$translation_file" ]; then
echo -e "${RED}Error: Translation file $translation_file not found${NC}"
return 1
fi
# Get all keys from source file
local source_keys
source_keys=$(get_keys "$source_file")
# Get all keys from translation file
local translation_keys
translation_keys=$(get_keys "$translation_file")
# Find missing keys
local missing_keys
missing_keys=$(comm -23 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Find extra keys (in translation but not in source)
local extra_keys
extra_keys=$(comm -13 <(echo "$source_keys" | sort) <(echo "$translation_keys" | sort))
# Count missing and extra keys
local missing_count
if [ -z "$missing_keys" ]; then
missing_count=0
else
missing_count=$(echo "$missing_keys" | wc -l | tr -d ' ')
fi
local extra_count
if [ -z "$extra_keys" ]; then
extra_count=0
else
extra_count=$(echo "$extra_keys" | wc -l | tr -d ' ')
fi
# Report results
if [ "$missing_count" -eq 0 ] && [ "$extra_count" -eq 0 ]; then
echo -e "${GREEN} Perfect! All keys match.${NC}"
return 0
fi
if [ "$missing_count" -gt 0 ]; then
echo -e "${RED} Missing $missing_count key(s) in translation:${NC}"
echo "$missing_keys" | while read -r key; do
echo -e " ${RED}$key${NC}"
done
echo ""
fi
if [ "$extra_count" -gt 0 ]; then
echo -e "${YELLOW} Extra $extra_count key(s) in translation (not in source):${NC}"
echo "$extra_keys" | while read -r key; do
echo -e " ${YELLOW}$key${NC}"
done
echo ""
fi
# Return error code if there are missing keys
if [ "$missing_count" -gt 0 ]; then
return 1
fi
return 0
}
# Main function
main() {
local translation_file="$1"
local source_file="$PROJECT_ROOT/$SOURCE_FILE"
local exit_code=0
# Change to project root directory
cd "$PROJECT_ROOT"
# If no file specified, check all translation files
if [ -z "$translation_file" ]; then
echo -e "${YELLOW}No file specified. Checking all translation files...${NC}"
echo ""
# Find all .json files in i18n directory except messages.json
local files
files=$(find src/lib/i18n -name "*.json" -not -name "messages.json" 2>/dev/null || true)
if [ -z "$files" ]; then
echo -e "${YELLOW}No translation files found in src/lib/i18n/${NC}"
return 0
fi
# Validate each file
echo "$files" | while read -r file; do
if [ -n "$file" ]; then
if ! validate_file "$file" "$source_file"; then
exit_code=1
fi
echo ""
fi
done
return $exit_code
fi
# Validate the specified file
if ! validate_file "$translation_file" "$source_file"; then
exit_code=1
fi
return $exit_code
} }
# Check if jq is installed # Check if jq is installed
if ! command -v jq &> /dev/null; then command -v jq >/dev/null 2>&1 || {
echo -e "${RED}Error: jq is required but not installed.${NC}" echo "Error: jq is required but not installed." >&2
echo "Please install jq:" echo "Please install jq:" >&2
echo " macOS: brew install jq" echo " macOS: brew install jq" >&2
echo " Ubuntu/Debian: sudo apt-get install jq" echo " Ubuntu/Debian: sudo apt-get install jq" >&2
echo " CentOS/RHEL: sudo yum install jq" echo " CentOS/RHEL: sudo yum install jq" >&2
exit 1 exit 127
}
# Parse arguments (handle --missing-only flag for Makefile compatibility)
TRANSLATION=""
SOURCE="$SOURCE_DEFAULT"
while [[ $# -gt 0 ]]; do
case "$1" in
--missing-only|-m)
# Flag is accepted but ignored (this script only does missing keys)
shift
;;
--help|-h)
usage
;;
-*)
echo "Error: Unknown option: $1" >&2
usage
;;
*)
if [[ -z "$TRANSLATION" ]]; then
TRANSLATION="$1"
elif [[ "$SOURCE" == "$SOURCE_DEFAULT" ]]; then
SOURCE="$1"
else
echo "Error: Too many arguments" >&2
usage
fi
shift
;;
esac
done
# Validate arguments
[[ -z "$TRANSLATION" ]] && {
echo "Error: Translation file is required" >&2
usage
}
# Validate files exist
[[ -f "$SOURCE" ]] || {
echo "Error: Source file not found: $SOURCE" >&2
exit 1
}
[[ -f "$TRANSLATION" ]] || {
echo "Error: Translation file not found: $TRANSLATION" >&2
exit 1
}
# Extract all keys from a JSON file (dot-joined paths to scalar values)
keys() {
jq -r 'paths(scalars) | join(".")' "$1" | sort -u
}
# Find missing keys: in SOURCE but not in TRANSLATION
missing=$(comm -23 <(keys "$SOURCE") <(keys "$TRANSLATION"))
if [[ -z "$missing" ]]; then
echo "No missing keys found."
exit 0
fi fi
# Handle help flag echo "Missing keys in $(basename "$TRANSLATION"):"
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then echo "$missing" | sed 's/^/ - /'
show_usage echo
exit 0 echo "Total missing keys: $(echo "$missing" | wc -l | tr -d ' ')"
fi exit 1
# Run main function
main "$1"

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { t } from '$lib/i18n/i18n.js';
interface Props {
emoji: string;
titleKey: string;
descriptionKey: string;
}
let { emoji, titleKey, descriptionKey }: Props = $props();
</script>
<div class="rounded-sm border p-4 text-center">
<div class="mx-auto mb-2 flex h-20 w-20 items-center justify-center rounded-full">
<span class="text-4xl">{emoji}</span>
</div>
<h3 class="mb-4 text-xl font-bold text-white">{t(titleKey)}</h3>
<p class="">{t(descriptionKey)}</p>
</div>

View File

@@ -0,0 +1,13 @@
const config = {
name: 'Cactoide Genesis',
instances: [
// {
// url: 'cactoide.org'
// }
// {
// url: 'YOUR_INSTANCE_URL'
// }
]
};
export default config;

View File

@@ -1,76 +1,18 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { logger } from '$lib/logger'; import { logger } from '$lib/logger';
import type { Event } from '$lib/types'; import type { Event } from '$lib/types';
import config from '../../federation.config.js'; import config from '$lib/config/federation.config.js';
console.log(config.instances);
interface FederationConfig {
name: string;
instances: Array<{ url: string }>;
}
interface FederationEventsResponse { interface FederationEventsResponse {
events: Array<Event & { federation?: boolean }>; events: Array<Event & { federation?: boolean }>;
count?: number; 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 * Fetches events from a single federated instance
*/ */
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> { async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
try { try {
// Ensure URL has protocol and append /api/federation/events
const apiUrl = `http://${instanceUrl}/api/federation/events`; const apiUrl = `http://${instanceUrl}/api/federation/events`;
logger.debug({ apiUrl }, 'Fetching events from federated instance'); logger.debug({ apiUrl }, 'Fetching events from federated instance');
@@ -120,18 +62,11 @@ async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
* Fetches events from all configured federated instances * Fetches events from all configured federated instances
*/ */
export async function fetchAllFederatedEvents(): Promise<Event[]> { export async function fetchAllFederatedEvents(): Promise<Event[]> {
const config = await readFederationConfig();
if (!config || !config.instances || config.instances.length === 0) { if (!config || !config.instances || config.instances.length === 0) {
logger.debug('No federation config or instances found'); logger.debug('No federation config or instances found');
return []; return [];
} }
logger.info(
{ instanceCount: config.instances.length },
'Fetching events from federated instances'
);
// Fetch from all instances in parallel // Fetch from all instances in parallel
const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url)); const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url));

View File

@@ -107,7 +107,10 @@
"description": "Create and manage event RSVPs. No registration required, instant sharing.", "description": "Create and manage event RSVPs. No registration required, instant sharing.",
"mainTitle": "Cactoide(ea)", "mainTitle": "Cactoide(ea)",
"subtitle": "The Ultimate RSVP Platform", "subtitle": "The Ultimate RSVP Platform",
"tagline": "Create, share, and manage events with zero friction.", "tagline": "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.",
"openSourceTitle": "Open Source & Self-Hostable",
"openSourceDescription": "Cactoide is open source and easily self-hostable. View the source code, contribute, or host your own instance.",
"viewOnGitHub": "View on GitHub",
"whyCactoideTitle": "Why Cactoide(ae)?🌵", "whyCactoideTitle": "Why Cactoide(ae)?🌵",
"whyCactoideDescription": "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.", "whyCactoideDescription": "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.",
"createEventNow": "Create Event Now", "createEventNow": "Create Event Now",
@@ -127,6 +130,10 @@
"smartLimitsDescription": "Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.", "smartLimitsDescription": "Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.",
"effortlessSimplicityTitle": "Effortless Simplicity", "effortlessSimplicityTitle": "Effortless Simplicity",
"effortlessSimplicityDescription": "Designed to be instantly clear and easy. No learning curve — just open, create, and go.", "effortlessSimplicityDescription": "Designed to be instantly clear and easy. No learning curve — just open, create, and go.",
"inviteLinksTitle": "Invite Links",
"inviteLinksDescription": "Create invite-only events with special links. Only people with the specific invite link can RSVP, giving you full control over who can attend.",
"federationTitle": "Federation",
"federationDescription": "Connect with other Cactoide instances to discover events across the network. Share your public events and create a decentralized event discovery network.",
"howItWorksTitle": "How It Works", "howItWorksTitle": "How It Works",
"step1Title": "Create Event", "step1Title": "Create Event",
"step1Description": "Fill out a simple form with event details. Choose between limited or unlimited capacity.", "step1Description": "Fill out a simple form with event details. Choose between limited or unlimited capacity.",

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { t } from '$lib/i18n/i18n.js'; import { t } from '$lib/i18n/i18n.js';
import FeatureCard from '$lib/components/FeatureCard.svelte';
</script> </script>
<svelte:head> <svelte:head>
@@ -22,6 +23,34 @@
{t('home.tagline')} {t('home.tagline')}
</p> </p>
<!-- Open Source Section -->
<div class="mt-8 flex items-center justify-center gap-3">
<a
href="https://github.com/polaroi8d/cactoide"
target="_blank"
rel="noopener noreferrer"
class="group flex items-center gap-2 rounded-sm border-2 border-violet-500/50 px-6 py-3 text-sm font-medium transition-all duration-300 hover:scale-105 hover:border-violet-500 hover:bg-violet-500/10 md:text-base"
aria-label={t('home.viewOnGitHub')}
>
<svg
class="h-5 w-5 transition-transform group-hover:scale-110"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clip-rule="evenodd"
/>
</svg>
<span>{t('home.viewOnGitHub')}</span>
</a>
</div>
<p class="mt-4 text-sm text-slate-400 md:text-base">
{t('home.openSourceDescription')}
</p>
<h2 class="mt-6 pt-8 text-xl md:text-2xl"> <h2 class="mt-6 pt-8 text-xl md:text-2xl">
{t('home.whyCactoideTitle')}<span class="text-violet-400" {t('home.whyCactoideTitle')}<span class="text-violet-400"
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span ><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
@@ -65,72 +94,54 @@
<h2 class=" mb-16 text-center text-4xl font-bold"> <h2 class=" mb-16 text-center text-4xl font-bold">
{t('home.whyCactoideFeatureTitle')} {t('home.whyCactoideFeatureTitle')}
</h2> </h2>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
<!-- Feature 1 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="🎯"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.instantEventCreationTitle"
<span class="text-4xl">🎯</span> descriptionKey="home.instantEventCreationDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.instantEventCreationTitle')}</h3>
<p class="">
{t('home.instantEventCreationDescription')}
</p>
</div>
<!-- Feature 2 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="🔗"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.oneClickSharingTitle"
<span class="text-4xl">🔗</span> descriptionKey="home.oneClickSharingDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.oneClickSharingTitle')}</h3>
<p class="">
{t('home.oneClickSharingDescription')}
</p>
</div>
<!-- Feature 2 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="🔍"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.allInOneClarityTitle"
<span class="text-4xl">🔍</span> descriptionKey="home.allInOneClarityDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.allInOneClarityTitle')}</h3>
<p class="">
{t('home.allInOneClarityDescription')}
</p>
</div>
<!-- Feature 4 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="👤"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.noHassleNoSignUpsTitle"
<span class="text-4xl">👤</span> descriptionKey="home.noHassleNoSignUpsDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.noHassleNoSignUpsTitle')}</h3>
<p class="">
{t('home.noHassleNoSignUpsDescription')}
</p>
</div>
<!-- Feature 5 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="🛡️"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.smartLimitsTitle"
<span class="text-4xl">🛡️</span> descriptionKey="home.smartLimitsDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.smartLimitsTitle')}</h3>
<p class="">
{t('home.smartLimitsDescription')}
</p>
</div>
<!-- Feature 5 --> <FeatureCard
<div class="rounded-sm border p-8 text-center"> emoji="✨"
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full"> titleKey="home.effortlessSimplicityTitle"
<span class="text-4xl"></span> descriptionKey="home.effortlessSimplicityDescription"
</div> />
<h3 class="mb-4 text-xl font-bold text-white">{t('home.effortlessSimplicityTitle')}</h3>
<p class=""> <FeatureCard
{t('home.effortlessSimplicityDescription')} emoji="🎫"
</p> titleKey="home.inviteLinksTitle"
</div> descriptionKey="home.inviteLinksDescription"
/>
<FeatureCard
emoji="🌐"
titleKey="home.federationTitle"
descriptionKey="home.federationDescription"
/>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -4,7 +4,7 @@ import { database } from '$lib/database/db';
import { events } from '$lib/database/schema'; import { events } from '$lib/database/schema';
import { eq, count } from 'drizzle-orm'; import { eq, count } from 'drizzle-orm';
import { logger } from '$lib/logger'; import { logger } from '$lib/logger';
import federationConfig from '../../../../../federation.config.js'; import federationConfig from '$lib/config/federation.config.js';
import { FEDERATION_INSTANCE } from '$env/static/private'; import { FEDERATION_INSTANCE } from '$env/static/private';

View File

@@ -1,6 +1,6 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { logger } from '$lib/logger'; import { logger } from '$lib/logger';
import federationConfig from '../../../federation.config.js'; import federationConfig from '$lib/config/federation.config.js';
interface InstanceInfo { interface InstanceInfo {
name: string; name: string;

View File

@@ -130,10 +130,7 @@
<p class="py-8 text-center text-slate-400"> <p class="py-8 text-center text-slate-400">
{t('instance.description')} {t('instance.description')}
<a {t('instance.configFile')}
href="https://github.com/cactoide/cactoide/blob/main/federation.config.js"
class="text-violet-300/80">{t('instance.configFile')}</a
>
{t('instance.file')} {t('instance.file')}
</p> </p>

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