mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-22 14:15:28 +00:00
Compare commits
30 Commits
fix/calend
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18afc43da | ||
|
|
662476f820 | ||
|
|
7ebf95bb16 | ||
|
|
0491ec4c4b | ||
|
|
fcdef065d7 | ||
|
|
a1fa879f36 | ||
|
|
b723aac180 | ||
|
|
277ad3ff14 | ||
|
|
52d48e4839 | ||
|
|
5b7178bec1 | ||
|
|
7531af9d29 | ||
|
|
94152b6740 | ||
|
|
6155cc44da | ||
|
|
719cd23350 | ||
|
|
87d2275373 | ||
|
|
6e314af82b | ||
|
|
2cb74bccd0 | ||
|
|
0ecaf54227 | ||
|
|
4179bca981 | ||
|
|
406a669a98 | ||
|
|
7d75020cc1 | ||
|
|
73c92b800a | ||
|
|
1faa45e76b | ||
|
|
8a45ad60fb | ||
|
|
258b822a27 | ||
|
|
c3f420df74 | ||
|
|
9f74d58db1 | ||
|
|
efe465d994 | ||
|
|
1b79e6da58 | ||
|
|
5ea620762a |
@@ -10,4 +10,14 @@ 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=false
|
||||
|
||||
|
||||
12
.env.example
12
.env.example
@@ -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
|
||||
|
||||
5
.github/workflows/build-and-push.yml
vendored
5
.github/workflows/build-and-push.yml
vendored
@@ -1,5 +1,7 @@
|
||||
# .github/workflows/docker-build-and-push.yml
|
||||
name: build & push the images
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -61,6 +63,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 }}
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -1,4 +1,7 @@
|
||||
name: test & build
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -29,6 +32,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 }}
|
||||
|
||||
@@ -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" ]
|
||||
|
||||
26
Makefile
26
Makefile
@@ -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
|
||||
DB_HOST ?= localhost
|
||||
@@ -19,10 +19,11 @@ help:
|
||||
@echo " up - Start all services"
|
||||
@echo " down - Stop all services"
|
||||
@echo " db-only - Start only the database"
|
||||
@echo " db-seed - Seed the database with sample data"
|
||||
@echo " logs - Show logs from all services"
|
||||
@echo " db-clean - Clean up all Docker resources"
|
||||
@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 " format - Format the project"
|
||||
@echo " migrate-up - Apply invite-only events migration"
|
||||
@@ -73,6 +74,17 @@ db-only:
|
||||
@echo "Starting only the database..."
|
||||
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
|
||||
logs:
|
||||
@echo "Showing logs from all services..."
|
||||
@@ -94,8 +106,10 @@ format:
|
||||
@echo "Formatting the project..."
|
||||
npm run format
|
||||
|
||||
#TODO: not working yet
|
||||
# List missing keys in a translation file
|
||||
i18n:
|
||||
@echo "Validating translation files..."
|
||||
@if [ -n "$(FILE)" ]; then \
|
||||
./scripts/i18n-check.sh $(FILE); \
|
||||
@if [ -z "$(FILE)" ]; then \
|
||||
echo "Error: FILE variable is required. Example: make i18n FILE=src/lib/i18n/it.json"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@./scripts/i18n-check.sh --missing-only $(FILE)
|
||||
|
||||
87
README.md
87
README.md
@@ -5,26 +5,34 @@ 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">
|
||||
<img alt="actoide" src="https://github.com/user-attachments/assets/a7f7a732-1279-486e-808c-1d2348c68780" width="840">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
#### 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
|
||||
|
||||
@@ -51,10 +59,68 @@ make db-only
|
||||
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`.
|
||||
|
||||
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`:
|
||||
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` file.
|
||||
|
||||
### Options
|
||||
|
||||
#### 1. Landing page option
|
||||
@@ -90,7 +156,6 @@ It isn’t 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.
|
||||
|
||||
@@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS events (
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
|
||||
attendee_limit INTEGER CHECK (attendee_limit > 0),
|
||||
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(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
@@ -34,6 +34,15 @@ CREATE TABLE IF NOT EXISTS rsvps (
|
||||
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
|
||||
-- =======================================
|
||||
@@ -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_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_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;
|
||||
@@ -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);
|
||||
@@ -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'));
|
||||
@@ -34,9 +34,13 @@ 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}
|
||||
LOG_PRETTY: ${LOG_PRETTY:-true}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-trace}
|
||||
PUBLIC_LANDING_INFO: ${PUBLIC_LANDING_INFO:-true}
|
||||
FEDERATION_INSTANCE: ${FEDERATION_INSTANCE:-true}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
BIN
docs/federation_example.png
Normal file
BIN
docs/federation_example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
# Compares a translation file against the source messages.json to find missing keys
|
||||
set -euo pipefail
|
||||
|
||||
set -e
|
||||
SOURCE_DEFAULT="src/lib/i18n/messages.json"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
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
|
||||
usage() {
|
||||
echo "Usage: $0 [--missing-only] <translation.json> [source_messages.json]"
|
||||
echo "Compares <translation.json> against messages.json and prints missing keys."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check if jq is installed
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo -e "${RED}Error: jq is required but not installed.${NC}"
|
||||
echo "Please install jq:"
|
||||
echo " macOS: brew install jq"
|
||||
echo " Ubuntu/Debian: sudo apt-get install jq"
|
||||
echo " CentOS/RHEL: sudo yum install jq"
|
||||
exit 1
|
||||
command -v jq >/dev/null 2>&1 || {
|
||||
echo "Error: jq is required but not installed." >&2
|
||||
echo "Please install jq:" >&2
|
||||
echo " macOS: brew install jq" >&2
|
||||
echo " Ubuntu/Debian: sudo apt-get install jq" >&2
|
||||
echo " CentOS/RHEL: sudo yum install jq" >&2
|
||||
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
|
||||
|
||||
# Handle help flag
|
||||
if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
|
||||
show_usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run main function
|
||||
main "$1"
|
||||
echo "Missing keys in $(basename "$TRANSLATION"):"
|
||||
echo "$missing" | sed 's/^/ - /'
|
||||
echo
|
||||
echo "Total missing keys: $(echo "$missing" | wc -l | tr -d ' ')"
|
||||
exit 1
|
||||
|
||||
19
src/lib/components/FeatureCard.svelte
Normal file
19
src/lib/components/FeatureCard.svelte
Normal 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>
|
||||
@@ -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'}
|
||||
|
||||
13
src/lib/config/federation.config.js
Normal file
13
src/lib/config/federation.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const config = {
|
||||
name: 'Cactoide Genesis',
|
||||
instances: [
|
||||
// {
|
||||
// url: 'cactoide.org'
|
||||
// }
|
||||
// {
|
||||
// url: 'YOUR_INSTANCE_URL'
|
||||
// }
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
81
src/lib/fetchFederatedEvents.ts
Normal file
81
src/lib/fetchFederatedEvents.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { logger } from '$lib/logger';
|
||||
import type { Event } from '$lib/types';
|
||||
|
||||
import config from '$lib/config/federation.config.js';
|
||||
|
||||
interface FederationEventsResponse {
|
||||
events: Array<Event & { federation?: boolean }>;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches events from a single federated instance
|
||||
*/
|
||||
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
|
||||
try {
|
||||
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[]> {
|
||||
if (!config || !config.instances || config.instances.length === 0) {
|
||||
logger.debug('No federation config or instances found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -99,14 +99,18 @@
|
||||
"home": "Home",
|
||||
"discover": "Discover",
|
||||
"create": "Create",
|
||||
"myEvents": "My Events"
|
||||
"myEvents": "My Events",
|
||||
"instance": "Instance"
|
||||
},
|
||||
"home": {
|
||||
"title": "Cactoide - The RSVP site",
|
||||
"description": "Create and manage event RSVPs. No registration required, instant sharing.",
|
||||
"mainTitle": "Cactoide(ea)",
|
||||
"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)?🌵",
|
||||
"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",
|
||||
@@ -126,6 +130,10 @@
|
||||
"smartLimitsDescription": "Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.",
|
||||
"effortlessSimplicityTitle": "Effortless Simplicity",
|
||||
"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",
|
||||
"step1Title": "Create Event",
|
||||
"step1Description": "Fill out a simple form with event details. Choose between limited or unlimited capacity.",
|
||||
@@ -250,6 +258,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/i18n/i18n.js';
|
||||
import FeatureCard from '$lib/components/FeatureCard.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -22,6 +23,34 @@
|
||||
{t('home.tagline')}
|
||||
</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">
|
||||
{t('home.whyCactoideTitle')}<span class="text-violet-400"
|
||||
><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">
|
||||
{t('home.whyCactoideFeatureTitle')}
|
||||
</h2>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Feature 1 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">🎯</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.instantEventCreationTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.instantEventCreationDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
<FeatureCard
|
||||
emoji="🎯"
|
||||
titleKey="home.instantEventCreationTitle"
|
||||
descriptionKey="home.instantEventCreationDescription"
|
||||
/>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">🔗</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.oneClickSharingTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.oneClickSharingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FeatureCard
|
||||
emoji="🔗"
|
||||
titleKey="home.oneClickSharingTitle"
|
||||
descriptionKey="home.oneClickSharingDescription"
|
||||
/>
|
||||
|
||||
<!-- Feature 2 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">🔍</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.allInOneClarityTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.allInOneClarityDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FeatureCard
|
||||
emoji="🔍"
|
||||
titleKey="home.allInOneClarityTitle"
|
||||
descriptionKey="home.allInOneClarityDescription"
|
||||
/>
|
||||
|
||||
<!-- Feature 4 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">👤</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.noHassleNoSignUpsTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.noHassleNoSignUpsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FeatureCard
|
||||
emoji="👤"
|
||||
titleKey="home.noHassleNoSignUpsTitle"
|
||||
descriptionKey="home.noHassleNoSignUpsDescription"
|
||||
/>
|
||||
|
||||
<!-- Feature 5 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">🛡️</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.smartLimitsTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.smartLimitsDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FeatureCard
|
||||
emoji="🛡️"
|
||||
titleKey="home.smartLimitsTitle"
|
||||
descriptionKey="home.smartLimitsDescription"
|
||||
/>
|
||||
|
||||
<!-- Feature 5 -->
|
||||
<div class="rounded-sm border p-8 text-center">
|
||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<span class="text-4xl">✨</span>
|
||||
</div>
|
||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.effortlessSimplicityTitle')}</h3>
|
||||
<p class="">
|
||||
{t('home.effortlessSimplicityDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<FeatureCard
|
||||
emoji="✨"
|
||||
titleKey="home.effortlessSimplicityTitle"
|
||||
descriptionKey="home.effortlessSimplicityDescription"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
emoji="🎫"
|
||||
titleKey="home.inviteLinksTitle"
|
||||
descriptionKey="home.inviteLinksDescription"
|
||||
/>
|
||||
|
||||
<FeatureCard
|
||||
emoji="🌐"
|
||||
titleKey="home.federationTitle"
|
||||
descriptionKey="home.federationDescription"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
55
src/routes/api/federation/events/+server.ts
Normal file
55
src/routes/api/federation/events/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
38
src/routes/api/federation/info/+server.ts
Normal file
38
src/routes/api/federation/info/+server.ts
Normal 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 '$lib/config/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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
28
src/routes/api/healthz/+server.ts
Normal file
28
src/routes/api/healthz/+server.ts
Normal 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' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,36 +320,58 @@
|
||||
<span>{event.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public' ? t('common.public') : t('common.inviteOnly')}
|
||||
</span>
|
||||
</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 ===
|
||||
'limited'
|
||||
? 'border-amber-600 text-amber-600'
|
||||
: 'border-teal-500 text-teal-500'}"
|
||||
>
|
||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span
|
||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||
'public'
|
||||
? 'border-teal-500 text-teal-500'
|
||||
: 'border-amber-600 text-amber-600'}"
|
||||
>
|
||||
{event.visibility === 'public'
|
||||
? t('common.public')
|
||||
: t('common.inviteOnly')}
|
||||
</span>
|
||||
</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<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>
|
||||
<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}
|
||||
|
||||
@@ -104,14 +104,15 @@ export const actions: Actions = {
|
||||
// Get current RSVPs
|
||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||
|
||||
// Calculate total attendees (including guests)
|
||||
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
||||
// Calculate remaining spots and ensure main attendee + guests fit
|
||||
const newAttendeesCount = 1 + numberOfGuests;
|
||||
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||
|
||||
// Check if event is full (for limited type events)
|
||||
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||
if (totalAttendees > eventData.attendeeLimit) {
|
||||
if (newAttendeesCount > remainingSpots) {
|
||||
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.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +129,15 @@ export const actions: Actions = {
|
||||
// Get current RSVPs
|
||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||
|
||||
// Calculate total attendees (including guests)
|
||||
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
||||
// Calculate remaining spots and ensure main attendee + guests fit
|
||||
const newAttendeesCount = 1 + numberOfGuests;
|
||||
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||
|
||||
// Check if event is full (for limited type events)
|
||||
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||
if (totalAttendees > eventData.attendeeLimit) {
|
||||
if (newAttendeesCount > remainingSpots) {
|
||||
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.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
119
src/routes/instance/+page.server.ts
Normal file
119
src/routes/instance/+page.server.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { logger } from '$lib/logger';
|
||||
import federationConfig from '$lib/config/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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
145
src/routes/instance/+page.svelte
Normal file
145
src/routes/instance/+page.svelte
Normal file
@@ -0,0 +1,145 @@
|
||||
<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')}
|
||||
{t('instance.configFile')}
|
||||
{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
151
static/llms.txt
Normal 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
|
||||
|
||||
Reference in New Issue
Block a user