Compare commits

..

13 Commits

Author SHA1 Message Date
Levente Orban
5809cb49ee fix: rvsp delet models 2025-10-26 17:41:44 +01:00
Levente Orban
93b0bac48a feat: invite only events 2025-10-26 16:47:51 +01:00
Levente Orban
c9c78d0ea6 feat(tmp): invite link feature 2025-10-26 15:38:12 +01:00
Levente Orban
f6b51232a7 fix: minor port, docs inconsistencies 2025-10-25 13:26:23 +02:00
Nandor Magyar
bb573c603a fix minor port, docs inconsistencies
Signed-off-by: Nandor Magyar <nandormagyar.it@gmail.com>
2025-10-24 15:31:31 +02:00
Levente Orban
75fa7a9528 feat: fail fast when database connection cannot be established 2025-10-23 09:54:04 +02:00
Levente Orban
eb5543fb9b feat: fail fast if db not connecting 2025-10-23 09:47:27 +02:00
Levente Orban
706e6cf712 chore(deps-dev): bump vite from 7.1.10 to 7.1.11 in the npm_and_yarn group across 1 directory 2025-10-21 10:12:34 +02:00
dependabot[bot]
c6decaa0d1 chore(deps-dev): bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 7.1.10 to 7.1.11
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.11
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 00:22:12 +00:00
Levente Orban
d193283b11 fix: creating an event and showing the wrong date 2025-10-20 21:22:30 +02:00
Levente Orban
accfd540f0 fix: creating an event and showing the wrong date 2025-10-20 11:43:34 +02:00
Levente Orban
9b1ef64618 fix: creating an event and showing the wrong date 2025-10-20 11:25:35 +02:00
Levente Orban
c340088434 fix: userId not generated in the first visit 2025-10-20 10:32:33 +02:00
29 changed files with 1432 additions and 232 deletions

13
.env.docker.example Normal file
View File

@@ -0,0 +1,13 @@
# Postgres configuration
POSTGRES_DB=cactoide_database
POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432
# Application configuration
DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoide_database"
APP_VERSION=latest
PORT=5173
HOSTNAME=0.0.0.0
PUBLIC_LANDING_INFO=true

View File

@@ -4,14 +4,10 @@ POSTGRES_USER=cactoide
POSTGRES_PASSWORD=cactoide_password
POSTGRES_PORT=5432
# localhost
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
# docker
# DATABASE_URL="postgres://cactoide:cactoide_password@postgres:5432/cactoide_database"
# Application configuration
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
APP_VERSION=latest
PORT=3000
PORT=5173
HOSTNAME=0.0.0.0
PUBLIC_LANDING_INFO=true

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ Thumbs.db
.env.*
!.env.example
!.env.test
!.env.docker.example
# Vite
vite.config.js.timestamp-*

View File

@@ -1,22 +1,54 @@
.PHONY: help build up db-only logs db-clean prune i18n lint format
.PHONY: help build up down db-only logs db-clean prune i18n lint format migrate-up migrate-down
# Database connection variables
DB_HOST ?= localhost
DB_PORT ?= 5432
DB_NAME ?= cactoide_database
DB_USER ?= cactoide
DB_PASSWORD ?= cactoide_password
# Migration variables
MIGRATIONS_DIR = database/migrations
# Database connection string
DB_URL = postgresql://$(DB_USER):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)
# Default target
help:
@echo "Cactoide Commands"
@echo ""
@echo "Main commands:"
@echo " make build - Build the Docker images"
@echo " make up - Start all services (database + app)"
@echo ""
@echo "Individual services:"
@echo " make db-only - Start only the database"
@echo ""
@echo "Utility commands:"
@echo " make logs - Show logs from all services"
@echo " make db-clean - Stop & remove database container"
@echo " make prune - Remove all containers, images, and volumes"
@echo " make i18n - Validate translation files against messages.json"
@echo " make help - Show this help message"
@echo "Available commands:"
@echo " build - Docker build the application"
@echo " up - Start all services"
@echo " down - Stop all services"
@echo " db-only - Start only the database"
@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 " lint - Lint the project"
@echo " format - Format the project"
@echo " migrate-up - Apply invite-only events migration"
@echo " migrate-down - Rollback invite-only events migration"
# Apply invite-only events migration
migrate-up:
@echo "Applying invite-only events migration..."
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" ]; then \
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" && \
echo "Migration applied successfully!"; \
else \
echo "Migration file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql"; \
exit 1; \
fi
# Rollback invite-only events migration
migrate-down:
@echo "Rolling back invite-only events migration..."
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" ]; then \
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" && \
echo "Migration rolled back successfully!"; \
else \
echo "Rollback file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql"; \
exit 1; \
fi
# Build the Docker images
build:
@@ -28,6 +60,14 @@ up:
@echo "Starting all services..."
docker compose up -d
down:
@echo "Stopping all services..."
docker compose down
db-clean:
@echo "Cleaning up all Docker resources..."
docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f
# Start only the database
db-only:
@echo "Starting only the database..."
@@ -38,23 +78,13 @@ logs:
@echo "Showing logs from all services..."
docker compose logs -f
db-clean:
@echo "Cleaning up all Docker resources..."
docker stop cactoide-db && docker rm cactoide-db && docker volume prune -f && docker network prune -f
# Clean up everything (containers, images, volumes)
prune:
@echo "Cleaning up all Docker resources..."
docker compose down -v --rmi all
# Validate translation files
i18n:
@echo "Validating translation files..."
@if [ -n "$(FILE)" ]; then \
./scripts/i18n-check.sh $(FILE); \
else \
./scripts/i18n-check.sh; \
fi
lint:
@echo "Linting the project..."
@@ -64,4 +94,8 @@ format:
@echo "Formatting the project..."
npm run format
#TODO: not working yet
i18n:
@echo "Validating translation files..."
@if [ -n "$(FILE)" ]; then \
./scripts/i18n-check.sh $(FILE); \

View File

@@ -37,7 +37,7 @@ Uses the [`docker-compose.yml`](docker-compose.yml) file to setup the applicatio
```bash
git clone https://github.com/polaroi8d/cactoide/
cd cactoide
cp env.example .env
cp .env.docker.example .env
docker compose up -d
```
@@ -46,7 +46,7 @@ docker compose up -d
```bash
git clone https://github.com/polaroi8d/cactoide/
cd cactoide
cp env.example .env
cp .env.example .env
make db-only
npm run dev -- --open
```

View File

@@ -43,21 +43,4 @@ 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);
-- =======================================
-- Triggers (updated_at maintenance)
-- =======================================
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_events_updated_at ON events;
CREATE TRIGGER update_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMIT;

View File

@@ -0,0 +1,25 @@
-- 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

@@ -0,0 +1,17 @@
-- 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'));

65
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "event-cactus",
"name": "cactoide",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-cactus",
"name": "cactoide",
"version": "0.1.1",
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
@@ -22,7 +22,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"drizzle-kit": "^0.31.4",
"drizzle-kit": "^0.31.5",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -35,7 +35,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.1.10"
"vite": "^7.1.11"
}
},
"node_modules/@drizzle-team/brocli": {
@@ -1643,6 +1643,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.30.1.tgz",
"integrity": "sha512-LyRpQmokZdMK4QOlGBbLX12c37IRnvC3rE6ysA4gLmBWMx5mheeEEjkZZXhtIL9Lze0BgMttaALFoROTx+kbEw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -1675,6 +1676,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.2.tgz",
"integrity": "sha512-7v+7OkUYelC2dhhYDAgX1qO2LcGscZ18Hi5kKzJQq7tQeXpH215dd0+J/HnX2zM5B3QKcIrTVqCGkZXAy5awYw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1",
@@ -2093,17 +2095,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -2156,6 +2147,7 @@
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.39.1",
"@typescript-eslint/types": "8.39.1",
@@ -2373,6 +2365,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2668,9 +2661,9 @@
"license": "MIT"
},
"node_modules/drizzle-kit": {
"version": "0.31.4",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz",
"integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==",
"version": "0.31.5",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz",
"integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2828,6 +2821,7 @@
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2895,6 +2889,7 @@
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4120,6 +4115,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4242,6 +4238,7 @@
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
"license": "Unlicense",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4266,6 +4263,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4282,6 +4280,7 @@
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
@@ -4475,6 +4474,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
"integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4674,6 +4674,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz",
"integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -4766,7 +4767,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.2.2",
@@ -4866,6 +4868,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4903,8 +4906,7 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/uri-js": {
"version": "4.4.1",
@@ -4924,10 +4926,11 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.10.tgz",
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -5052,20 +5055,6 @@
"node": ">=18"
}
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -22,7 +22,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"drizzle-kit": "^0.31.4",
"drizzle-kit": "^0.31.5",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -35,7 +35,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.1.10"
"vite": "^7.1.11"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",

View File

@@ -1,8 +1,30 @@
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { generateUserId } from '$lib/generateUserId.js';
import { ensureDatabaseConnection } from '$lib/database/healthCheck';
// Global flag to track if database health check has been performed
let dbHealthCheckPerformed = false;
export const handle: Handle = async ({ event, resolve }) => {
// Perform database health check only once during application startup
if (!dbHealthCheckPerformed) {
try {
await ensureDatabaseConnection({
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000,
timeout: 5000
});
dbHealthCheckPerformed = true;
} catch (error) {
console.error('Database health check failed:', error);
// The ensureDatabaseConnection function will exit the process
// if the database is unavailable, so this catch is just for safety
process.exit(1);
}
}
const cactoideUserId = event.cookies.get('cactoideUserId');
const userId = generateUserId();

View File

@@ -15,7 +15,10 @@ export interface CalendarEvent {
* Formats a date and time string for iCal format (UTC)
*/
export const formatDateForICal = (date: string, time: string): string => {
const eventDate = new Date(`${date}T${time}`);
// Parse date and time as local timezone to avoid timezone issues
const [year, month, day] = date.split('-').map(Number);
const [hours, minutes, seconds] = time.split(':').map(Number);
const eventDate = new Date(year, month - 1, day, hours, minutes, seconds || 0);
return eventDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
};

View File

@@ -0,0 +1,104 @@
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
interface HealthCheckOptions {
maxRetries?: number;
baseDelay?: number;
maxDelay?: number;
timeout?: number;
}
interface HealthCheckResult {
success: boolean;
error?: string;
attempts: number;
duration?: number;
}
/**
* Performs a database health check with retry logic and exponential backoff
*/
export async function checkDatabaseHealth(
options: HealthCheckOptions = {}
): Promise<HealthCheckResult> {
const {
maxRetries = 3,
baseDelay = 1000, // 1 second
maxDelay = 10000, // 10 seconds
timeout = 5000 // 5 seconds
} = options;
if (!env.DATABASE_URL) {
console.error('DATABASE_URL environment variable is not set');
return {
success: false,
error: 'DATABASE_URL environment variable is not set',
attempts: 0
};
}
let lastError: Error | null = null;
console.log(`Starting database health check (max retries: ${maxRetries})`);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
console.log(`Attempt ${attempt}/${maxRetries} - Testing database connection`);
try {
// Create a new connection for the health check
const client = postgres(env.DATABASE_URL, {
max: 1,
idle_timeout: 20,
connect_timeout: timeout / 1000, // Convert to seconds
onnotice: () => {} // Suppress notices
});
// Test the connection with a simple query
await client`SELECT 1 as health_check`;
await client.end();
console.log(`Database connection successful on attempt ${attempt}.`);
return {
success: true,
attempts: attempt
};
} catch (error) {
lastError = error as Error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`Connection failed on attempt ${attempt}: ${errorMessage}`);
// Don't wait after the last attempt
if (attempt < maxRetries) {
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
console.log(`Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
const finalError = lastError?.message || 'Unknown database connection error';
console.error(`All ${maxRetries} attempts failed. Final error: ${finalError}`);
return {
success: false,
error: finalError,
attempts: maxRetries
};
}
/**
* Runs database health check and exits the process if it fails
*/
export async function ensureDatabaseConnection(options?: HealthCheckOptions): Promise<void> {
const result = await checkDatabaseHealth(options);
if (!result.success) {
console.error('Database connection failed after all retry attempts');
console.error(`Error: ${result.error}`);
console.error(`Attempts made: ${result.attempts}`);
console.error('Exiting application...');
process.exit(1);
}
}

View File

@@ -16,7 +16,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
// --- Enums (matching the SQL CHECK constraints)
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
export const visibilityEnum = pgEnum('visibility', ['public', 'private']);
export const visibilityEnum = pgEnum('visibility', ['public', 'private', 'invite-only']);
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
// --- Events table
@@ -71,11 +71,31 @@ export const rsvps = pgTable(
})
);
// --- Invite Tokens table
export const inviteTokens = pgTable(
'invite_tokens',
{
id: uuid('id').defaultRandom().primaryKey(),
eventId: varchar('event_id', { length: 8 })
.notNull()
.references(() => events.id, { onDelete: 'cascade' }),
token: varchar('token', { length: 32 }).notNull().unique(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow()
},
(t) => ({
idxInviteTokensEventId: index('idx_invite_tokens_event_id').on(t.eventId),
idxInviteTokensToken: index('idx_invite_tokens_token').on(t.token),
idxInviteTokensExpiresAt: index('idx_invite_tokens_expires_at').on(t.expiresAt)
})
);
// --- Relations (optional but handy for type safety)
import { relations } from 'drizzle-orm';
export const eventsRelations = relations(events, ({ many }) => ({
rsvps: many(rsvps)
rsvps: many(rsvps),
inviteTokens: many(inviteTokens)
}));
export const rsvpsRelations = relations(rsvps, ({ one }) => ({
@@ -85,16 +105,30 @@ export const rsvpsRelations = relations(rsvps, ({ one }) => ({
})
}));
export const inviteTokensRelations = relations(inviteTokens, ({ one }) => ({
event: one(events, {
fields: [inviteTokens.eventId],
references: [events.id]
})
}));
// --- Inferred types for use in the application
export type Event = InferSelectModel<typeof events>;
export type NewEvent = InferInsertModel<typeof events>;
export type Rsvp = InferSelectModel<typeof rsvps>;
export type NewRsvp = InferInsertModel<typeof rsvps>;
export type InviteToken = InferSelectModel<typeof inviteTokens>;
export type NewInviteToken = InferInsertModel<typeof inviteTokens>;
// --- Additional utility types
export type EventWithRsvps = Event & {
rsvps: Rsvp[];
};
export type EventWithInviteTokens = Event & {
inviteTokens: InviteToken[];
};
export type CreateEventData = Omit<NewEvent, 'id' | 'createdAt' | 'updatedAt'>;
export type CreateRsvpData = Omit<NewRsvp, 'id' | 'createdAt'>;
export type CreateInviteTokenData = Omit<NewInviteToken, 'id' | 'createdAt'>;

View File

@@ -1,11 +1,14 @@
import type { Event } from './types';
export const formatDate = (dateString: string): string => {
const date = new Date(dateString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}/${month}/${day}`;
// Parse the date string as local date to avoid timezone issues
// Split the date string and create a Date object in local timezone
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(year, month - 1, day); // month is 0-indexed in Date constructor
const formattedYear = date.getFullYear();
const formattedMonth = String(date.getMonth() + 1).padStart(2, '0');
const formattedDay = String(date.getDate()).padStart(2, '0');
return `${formattedYear}/${formattedMonth}/${formattedDay}`;
};
export const formatTime = (timeString: string): string => {
@@ -17,7 +20,10 @@ export const formatTime = (timeString: string): string => {
export const isEventInTimeRange = (event: Event, timeFilter: string): boolean => {
if (timeFilter === 'any') return true;
const eventDate = new Date(`${event.date}T${event.time}`);
// Parse date and time as local timezone to avoid timezone issues
const [year, month, day] = event.date.split('-').map(Number);
const [hours, minutes, seconds] = event.time.split(':').map(Number);
const eventDate = new Date(year, month - 1, day, hours, minutes, seconds || 0);
const now = new Date();
// Handle temporal status filters

View File

@@ -41,7 +41,6 @@
"numberOfGuests": "Numero di Ospiti",
"addGuests": "Aggiungi ospiti",
"joinEvent": "Partecipa all'Evento",
"copyLink": "Copia Link",
"addToCalendar": "Aggiungi al Calendario",
"close": "Chiudi",
"closeModal": "Chiudi finestra",
@@ -64,7 +63,6 @@
"eventNotFound": "Evento Non Trovato",
"eventIsFull": "L'Evento è Pieno!",
"maximumCapacityReached": "Raggiunta la capacità massima",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
@@ -188,8 +186,10 @@
"noAttendeesYet": "Ancora nessun partecipante",
"beFirstToJoin": "Sii il primo a partecipare!",
"copyLinkButton": "Copia Link",
"copyInviteLinkButton": "Copia Link Invito",
"addToCalendarButton": "Aggiungi al Calendario",
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
"inviteLinkCopied": "Link invito copiato negli appunti!",
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
"failedToAddRsvp": "Impossibile aggiungere RSVP",
@@ -208,7 +208,8 @@
"viewEventAriaLabel": "Visualizza evento",
"editEventAriaLabel": "Modifica evento",
"deleteEventAriaLabel": "Elimina evento",
"removeRsvpAriaLabel": "Rimuovi RSVP"
"removeRsvpAriaLabel": "Rimuovi RSVP",
"inviteLinkExpiresAt": "Questo link scade quando l'evento inizia: {time}"
},
"discover": {
"title": "Scopri Eventi - Cactoide",

View File

@@ -27,6 +27,7 @@
"visibility": "Visibility",
"public": "Public",
"private": "Private",
"inviteOnly": "Invite Only",
"limited": "Limited",
"unlimited": "Unlimited",
"capacity": "Capacity",
@@ -41,7 +42,6 @@
"numberOfGuests": "Number of Guests",
"addGuests": "Add guest users",
"joinEvent": "Join Event",
"copyLink": "Copy Link",
"addToCalendar": "Add to Calendar",
"close": "Close",
"closeModal": "Close modal",
@@ -64,9 +64,11 @@
"eventNotFound": "Event Not Found",
"eventIsFull": "Event is Full!",
"maximumCapacityReached": "Maximum capacity reached",
"eventLinkCopied": "Event link copied to clipboard!",
"rsvpAddedSuccessfully": "RSVP added successfully!",
"removedRsvpSuccessfully": "Removed RSVP successfully.",
"inviteRequiredToDetails": "This event requires an invite link to see the details.",
"invalidInviteToken": "Invalid invite token",
"inviteTokenExpired": "Invite token has expired",
"anUnexpectedErrorOccurred": "An unexpected error occurred.",
"somethingWentWrong": "Something went wrong. Please try again.",
"failedToAddRsvp": "Failed to add RSVP",
@@ -160,8 +162,10 @@
"visibilityLabel": "Visibility",
"publicOption": "🌍 Public",
"privateOption": "🔒 Private",
"inviteOnlyOption": "🚧 Invite Only",
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
"privateDescription": "Private events are only visible to you and people you share the link with.",
"inviteOnlyDescription": "Event is public but requires a special invite link to attend.",
"creatingEvent": "Creating Event...",
"createEventButton": "Create Event"
},
@@ -188,9 +192,14 @@
"noAttendeesYet": "No attendees yet",
"beFirstToJoin": "Be the first to join!",
"copyLinkButton": "Copy Link",
"copyInviteLinkButton": "Copy Invite Link",
"inviteOnlyBadge": "Invite Only",
"inviteOnlyBannerTitle": "Invite Only Event",
"inviteOnlyBannerSubtitle": "You're viewing this event through a special invite link",
"addToCalendarButton": "Add to Calendar",
"eventLinkCopied": "Event link copied to clipboard!",
"rsvpAddedSuccessfully": "RSVP added successfully!",
"eventLinkCopied": "Event link copied to clipboard.",
"inviteLinkCopied": "Invite link copied to clipboard.",
"rsvpAddedSuccessfully": "RSVP added successfully.",
"removedRsvpSuccessfully": "Removed RSVP successfully.",
"failedToAddRsvp": "Failed to add RSVP",
"failedToRemoveRsvp": "Failed to remove RSVP",
@@ -208,7 +217,8 @@
"viewEventAriaLabel": "View event",
"editEventAriaLabel": "Edit event",
"deleteEventAriaLabel": "Delete event",
"removeRsvpAriaLabel": "Remove RSVP"
"removeRsvpAriaLabel": "Remove RSVP",
"inviteLinkExpiresAt": "This link expires when the event starts: {time}"
},
"discover": {
"title": "Discover Events - Cactoide",

View File

@@ -0,0 +1,33 @@
import { randomBytes } from 'crypto';
/**
* Generates a secure random token for invite links
* @param length - Length of the token (default: 32)
* @returns A random hex string
*/
export function generateInviteToken(length: number = 32): string {
return randomBytes(length / 2).toString('hex');
}
/**
* Calculates the expiration time for an invite token
* The token expires when the event starts
* @param eventDate - The event date in YYYY-MM-DD format
* @param eventTime - The event time in HH:MM:SS format
* @returns ISO string of the expiration time
*/
export function calculateTokenExpiration(eventDate: string, eventTime: string): string {
const eventDateTime = new Date(`${eventDate}T${eventTime}`);
return eventDateTime.toISOString();
}
/**
* Checks if an invite token is still valid
* @param expiresAt - The expiration time as ISO string
* @returns true if token is still valid, false otherwise
*/
export function isTokenValid(expiresAt: string): boolean {
const now = new Date();
const expiration = new Date(expiresAt);
return now < expiration;
}

View File

@@ -1,5 +1,5 @@
export type EventType = 'limited' | 'unlimited';
export type EventVisibility = 'public' | 'private';
export type EventVisibility = 'public' | 'private' | 'invite-only';
export type ActionType = 'add' | 'remove';
export type LocationType = 'none' | 'text' | 'maps';
@@ -62,3 +62,11 @@ export interface DatabaseRSVP {
user_id: string;
created_at: string;
}
export interface InviteToken {
id: string;
event_id: string;
token: string;
expires_at: string;
created_at: string;
}

View File

@@ -1,7 +1,8 @@
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { events, inviteTokens } from '$lib/database/schema';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { generateInviteToken, calculateTokenExpiration } from '$lib/inviteTokenHelpers.js';
// Generate a random URL-friendly ID
function generateEventId(): string {
@@ -25,7 +26,7 @@ export const actions: Actions = {
const locationUrl = formData.get('location_url') as string;
const type = formData.get('type') as 'limited' | 'unlimited';
const attendeeLimit = formData.get('attendee_limit') as string;
const visibility = formData.get('visibility') as 'public' | 'private';
const visibility = formData.get('visibility') as 'public' | 'private' | 'invite-only';
const userId = cookies.get('cactoideUserId');
// Validation
@@ -56,7 +57,13 @@ export const actions: Actions = {
});
}
if (new Date(date) < new Date()) {
// Check if date is in the past using local timezone
const [year, month, day] = date.split('-').map(Number);
const eventDate = new Date(year, month - 1, day);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (eventDate < today) {
return fail(400, {
error: 'Date cannot be in the past.',
values: {
@@ -92,6 +99,7 @@ export const actions: Actions = {
const eventId = generateEventId();
// Create the event
await database
.insert(events)
.values({
@@ -105,13 +113,31 @@ export const actions: Actions = {
type: type,
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
visibility: visibility,
userId: userId
userId: userId!
})
.catch((error) => {
console.error('Unexpected error', error);
throw error;
});
// Generate invite token for invite-only events
if (visibility === 'invite-only') {
const token = generateInviteToken();
const expiresAt = calculateTokenExpiration(date, time);
await database
.insert(inviteTokens)
.values({
eventId: eventId,
token: token,
expiresAt: new Date(expiresAt)
})
.catch((error) => {
console.error('Error creating invite token', error);
throw error;
});
}
throw redirect(303, `/event/${eventId}`);
}
};

View File

@@ -15,7 +15,7 @@
location_url: '',
type: 'unlimited',
attendee_limit: undefined,
visibility: 'public'
visibility: 'public' as 'public' | 'private' | 'invite-only'
};
let errors: Record<string, string> = {};
@@ -317,13 +317,13 @@
{t('create.visibilityLabel')}
<span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'public'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700'}"
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => (eventData.visibility = 'public')}
>
{t('create.publicOption')}
@@ -338,11 +338,23 @@
>
{t('create.privateOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'invite-only'
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => (eventData.visibility = 'invite-only')}
>
{t('create.inviteOnlyOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400 italic">
{eventData.visibility === 'public'
? t('create.publicDescription')
: t('create.privateDescription')}
: eventData.visibility === 'private'
? t('create.privateDescription')
: 'Event is public but requires a special invite link to attend'}
</p>
</fieldset>
</div>

View File

@@ -1,15 +1,15 @@
import { database } from '$lib/database/db';
import { eq, desc } from 'drizzle-orm';
import { desc, inArray } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
import { events } from '$lib/database/schema';
export const load: PageServerLoad = async () => {
try {
// Fetch all public events ordered by creation date (newest first)
// Fetch all non-private events (public and invite-only) ordered by creation date (newest first)
const publicEvents = await database
.select()
.from(events)
.where(eq(events.visibility, 'public'))
.where(inArray(events.visibility, ['public', 'invite-only']))
.orderBy(desc(events.createdAt));
// Transform the database events to match the expected Event interface

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import type { Event, EventType } from '$lib/types';
import { goto } from '$app/navigation';
import type { PageData } from '../$types';
import { formatTime, formatDate, isEventInTimeRange } from '$lib/dateHelpers';
import { t } from '$lib/i18n/i18n.js';
import Fuse from 'fuse.js';
type DiscoverPageData = {
events: Event[];
};
let publicEvents: Event[] = [];
let error = '';
let searchQuery = '';
@@ -16,7 +19,7 @@
let showFilters = false;
let fuse: Fuse<Event>;
export let data: PageData;
export let data: DiscoverPageData;
// Use the server-side data
$: publicEvents = data?.events || [];
@@ -67,8 +70,15 @@
// Sort events by date and time
events = events.sort((a, b) => {
const dateA = new Date(`${a.date}T${a.time}`);
const dateB = new Date(`${b.date}T${b.time}`);
// Parse dates as local timezone to avoid timezone issues
const parseEventDateTime = (event: Event) => {
const [year, month, day] = event.date.split('-').map(Number);
const [hours, minutes, seconds] = event.time.split(':').map(Number);
return new Date(year, month - 1, day, hours, minutes, seconds || 0);
};
const dateA = parseEventDateTime(a);
const dateB = parseEventDateTime(b);
if (selectedSortOrder === 'asc') {
return dateA.getTime() - dateB.getTime();
@@ -314,6 +324,16 @@
{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>
</div>
</div>

View File

@@ -25,6 +25,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
const event = eventData[0];
const eventRsvps = rsvpData;
// Check if this is an invite-only event
if (event.visibility === 'invite-only') {
// For invite-only events, check if user is the event creator
const userId = cookies.get('cactoideUserId');
if (event.userId !== userId) {
// User is not the creator, redirect to a message about needing invite
throw error(403, 'This event requires an invite link to view');
}
}
// Transform the data to match the expected interface
const transformedEvent = {
id: event.id,
@@ -85,6 +95,11 @@ export const actions: Actions = {
return fail(404, { error: 'Event not found' });
}
// Check if this is an invite-only event
if (eventData.visibility === 'invite-only') {
return fail(403, { error: 'This event requires an invite link to RSVP' });
}
// Get current RSVPs
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));

View File

@@ -10,23 +10,28 @@
import { t } from '$lib/i18n/i18n.js';
export let data: { event: Event; rsvps: RSVP[]; userId: string };
export let form;
type FormDataLocal = { success?: boolean; error?: string; type?: 'add' | 'remove' | 'copy' };
export let form: FormDataLocal | undefined;
let event: Event;
let rsvps: RSVP[] = [];
let newAttendeeName = '';
let isAddingRSVP = false;
let error = '';
let success = '';
let success = ''; // TODO: change to boolean and refactor with 482-506
let addGuests = false;
let numberOfGuests = 1;
let showCalendarModal = false;
let calendarEvent: CalendarEvent;
let toastType: 'add' | 'remove' | 'copy' | null = null;
let typeToShow: 'add' | 'remove' | 'copy' | undefined;
let successHideTimer: number | null = null;
// Use server-side data
$: event = data.event;
$: rsvps = data.rsvps;
$: currentUserId = data.userId;
$: isEventCreator = event.user_id === currentUserId;
// Create calendar event object when event data changes
$: if (event && browser) {
@@ -45,24 +50,47 @@
success = '';
}
// Handle form success from server
$: if (form?.success) {
success = 'RSVP added successfully!';
const handleFormSuccess = () => {
if (form?.type === 'add') {
success = 'RSVP added successfully!';
} else {
success = 'RSVP removed successfully.';
}
error = '';
newAttendeeName = '';
addGuests = false;
numberOfGuests = 1;
}
toastType = form?.type || 'add';
if (browser) {
if (successHideTimer) clearTimeout(successHideTimer);
successHideTimer = window.setTimeout(() => {
success = '';
toastType = null;
}, 3000);
}
};
// Handle form success from server
$: if (form?.success) handleFormSuccess();
// Derive toast type from local or server form
$: typeToShow = toastType ?? form?.type;
const eventId = $page.params.id || '';
const copyEventLink = () => {
if (browser) {
if (browser && isEventCreator) {
const url = `${$page.url.origin}/event/${eventId}`;
navigator.clipboard.writeText(url).then(() => {
success = 'Event link copied to clipboard!';
toastType = 'copy';
success = t('event.eventLinkCopied');
setTimeout(() => {
success = '';
toastType = null;
}, 3000);
});
}
@@ -71,6 +99,7 @@
const clearMessages = () => {
error = '';
success = '';
toastType = null;
};
// Calendar modal functions
@@ -208,7 +237,13 @@
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
{#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
{#if event.visibility === 'invite-only'}
<div class="py-6 text-center">
<div class="mb-3 text-4xl">🎫</div>
<p class="font-semibold text-amber-400">{t('event.inviteOnlyBannerTitle')}</p>
<p class="mt-1 text-sm text-amber-300">{t('common.inviteRequiredToDetails')}</p>
</div>
{:else if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
<div class="py-6 text-center">
<div class="mb-3 text-4xl text-red-400">🚫</div>
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
@@ -316,101 +351,113 @@
</div>
<!-- Attendees List -->
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
<span class="text-2xl font-bold">{rsvps.length}</span>
</div>
{#if rsvps.length === 0}
<div class="text-dark-400 py-8 text-center">
<p>{t('event.noAttendeesYet')}</p>
<p class="mt-1 text-sm">{t('event.beFirstToJoin')}</p>
{#if event.visibility !== 'invite-only'}
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
<span class="text-2xl font-bold">{rsvps.length}</span>
</div>
{:else}
<div class="space-y-3">
{#each rsvps as attendee, i (i)}
<div
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
>
<div class="flex items-center space-x-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
"'s Guest"
)
? 'text-white-400 bg-violet-500/40'
: 'bg-violet-500/20 text-violet-400'}"
>
{attendee.name.charAt(0).toUpperCase()}
</div>
<div>
<p
class="font-medium text-white {attendee.name.includes("'s Guest")
? 'text-amber-300'
: ''}"
{#if rsvps.length === 0}
<div class="text-dark-400 py-8 text-center">
<p>{t('event.noAttendeesYet')}</p>
<p class="mt-1 text-sm">{t('event.beFirstToJoin')}</p>
</div>
{:else}
<div class="space-y-3">
{#each rsvps as attendee, i (i)}
<div
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
>
<div class="flex items-center space-x-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
"'s Guest"
)
? 'text-white-400 bg-violet-500/40'
: 'bg-violet-500/20 text-violet-400'}"
>
{attendee.name}
</p>
<p class="text-xs text-violet-400">
{(() => {
const date = new Date(attendee.created_at);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
})()}
</p>
{attendee.name.charAt(0).toUpperCase()}
</div>
<div>
<p
class="font-medium text-white {attendee.name.includes("'s Guest")
? 'text-amber-300'
: ''}"
>
{attendee.name}
</p>
<p class="text-xs text-violet-400">
{(() => {
const date = new Date(attendee.created_at);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
})()}
</p>
</div>
</div>
{#if attendee.user_id === currentUserId}
<form
method="POST"
action="?/removeRSVP"
use:enhance={() => {
clearMessages();
return async ({ result, update }) => {
if (result.type === 'failure') {
error = String(result.data?.error || 'Failed to remove RSVP');
}
update();
};
}}
style="display: inline;"
>
<input type="hidden" name="rsvpId" value={attendee.id} />
<button
type="submit"
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
aria-label={t('event.removeRsvpAriaLabel')}
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</form>
{/if}
</div>
{#if attendee.user_id === currentUserId}
<form
method="POST"
action="?/removeRSVP"
use:enhance={() => {
clearMessages();
return async ({ result, update }) => {
if (result.type === 'failure') {
error = String(result.data?.error || 'Failed to remove RSVP');
}
update();
};
}}
style="display: inline;"
>
<input type="hidden" name="rsvpId" value={attendee.id} />
<button
type="submit"
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
aria-label={t('event.removeRsvpAriaLabel')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</form>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Action Buttons -->
<div class="max-w-2xl space-y-3">
<button
on:click={copyEventLink}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{t('event.copyLinkButton')}
</button>
{#if event.visibility !== 'invite-only'}
<button
on:click={copyEventLink}
disabled={!isEventCreator}
class="w-full rounded-sm border-2 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 {isEventCreator
? 'border-violet-500 bg-violet-400/20 hover:bg-violet-400/70'
: 'cursor-not-allowed border-gray-500 bg-gray-600/20 hover:bg-gray-600/30'}"
>
{t('event.copyLinkButton')}
</button>
{/if}
<button
on:click={openCalendarModal}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
@@ -436,18 +483,26 @@
<!-- Success/Error Messages -->
{#if success}
{#if form?.type === 'add'}
{#if typeToShow === 'add'}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
>
{success}
</div>
{:else if form?.type === 'remove'}
{:else if typeToShow === 'remove'}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
>
{t('event.removedRsvpSuccessfully')}
</div>
{:else if typeToShow === 'copy'}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
>
{t('event.eventLinkCopied')}
</div>
{:else}
<!-- fallback -->
{/if}
{/if}

View File

@@ -1,5 +1,5 @@
import { database } from '$lib/database/db';
import { events } from '$lib/database/schema';
import { events, inviteTokens } from '$lib/database/schema';
import { eq, and } from 'drizzle-orm';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -23,8 +23,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
throw redirect(303, '/event');
}
// Fetch invite token if this is an invite-only event
let inviteToken = null;
if (event[0].visibility === 'invite-only') {
const tokenData = await database
.select()
.from(inviteTokens)
.where(eq(inviteTokens.eventId, eventId))
.limit(1);
if (tokenData.length > 0) {
inviteToken = {
id: tokenData[0].id,
event_id: tokenData[0].eventId,
token: tokenData[0].token,
expires_at: tokenData[0].expiresAt.toISOString(),
created_at: tokenData[0].createdAt?.toISOString() || new Date().toISOString()
};
}
}
return {
event: event[0]
event: event[0],
inviteToken,
userId
};
};
@@ -86,8 +108,9 @@ export const actions: Actions = {
});
}
// Check if date is in the past (but allow editing past events for corrections)
const eventDate = new Date(date);
// Check if date is in the past using local timezone (but allow editing past events for corrections)
const [year, month, day] = date.split('-').map(Number);
const eventDate = new Date(year, month - 1, day);
const today = new Date();
today.setHours(0, 0, 0, 0);

View File

@@ -21,6 +21,10 @@
let errors: Record<string, string> = {};
let isSubmitting = false;
let inviteToken = data.inviteToken;
let showInviteLinkToast = false;
let toastHideTimer: number | null = null;
// Get today's date in YYYY-MM-DD format for min attribute
const today = new Date().toISOString().split('T')[0];
@@ -67,6 +71,24 @@
const handleCancel = () => {
goto(`/event/${data.event.id}`);
};
const copyInviteLink = async () => {
if (inviteToken) {
const inviteUrl = `${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`;
try {
await navigator.clipboard.writeText(inviteUrl);
showInviteLinkToast = true;
// Auto-hide toast after 3 seconds
if (toastHideTimer) clearTimeout(toastHideTimer);
toastHideTimer = window.setTimeout(() => {
showInviteLinkToast = false;
}, 3000);
} catch (err) {
console.error('Failed to copy invite link:', err);
}
}
};
</script>
<svelte:head>
@@ -315,7 +337,7 @@
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
</legend>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-3 gap-3">
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
@@ -336,15 +358,59 @@
>
{t('create.privateOption')}
</button>
<button
type="button"
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
'invite-only'
? ' border-amber-500 bg-amber-400/20 font-semibold hover:bg-amber-400/70'
: 'border-dark-300 text-dark-700 bg-gray-600/20 hover:bg-gray-600/70'}"
on:click={() => (eventData.visibility = 'invite-only')}
>
{t('create.inviteOnlyOption')}
</button>
</div>
<p class="mt-2 text-xs text-slate-400">
{eventData.visibility === 'public'
? t('create.publicDescription')
: t('create.privateDescription')}
: eventData.visibility === 'private'
? t('create.privateDescription')
: t('create.inviteOnlyDescription')}
</p>
</fieldset>
</div>
<!-- Invite Link Section (only for invite-only events and event creator) -->
{#if eventData.visibility === 'invite-only' && inviteToken && data.event.userId === data.userId}
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-semibold text-amber-400">Invite Link</h3>
</div>
<div class="space-y-3">
<div class="flex items-center space-x-2">
<input
type="text"
value={`${window.location.origin}/event/${data.event.id}/invite/${inviteToken.token}`}
readonly
class="flex-1 rounded-sm border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"
/>
<button
type="button"
on:click={copyInviteLink}
class="rounded-sm border border-amber-300 bg-amber-200 px-3 py-2 text-sm font-medium text-amber-900 hover:bg-amber-300"
>
{t('event.copyInviteLinkButton')}
</button>
</div>
<p class="text-xs text-amber-300">
{t('event.inviteLinkExpiresAt', {
time: new Date(inviteToken.expires_at).toLocaleString()
})}
</p>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex space-x-3">
<button
@@ -374,3 +440,12 @@
</div>
</div>
</div>
<!-- Invite Link Toast -->
{#if showInviteLinkToast}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
>
{t('event.inviteLinkCopied')}
</div>
{/if}

View File

@@ -0,0 +1,223 @@
import { database } from '$lib/database/db';
import { events, rsvps, inviteTokens } from '$lib/database/schema';
import { eq, and, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { isTokenValid } from '$lib/inviteTokenHelpers.js';
export const load: PageServerLoad = async ({ params, cookies }) => {
const eventId = params.id;
const token = params.token;
if (!eventId || !token) {
throw error(404, 'Event or token not found');
}
try {
// Fetch event, RSVPs, and invite token in parallel
const [eventData, rsvpData, tokenData] = await Promise.all([
database.select().from(events).where(eq(events.id, eventId)).limit(1),
database.select().from(rsvps).where(eq(rsvps.eventId, eventId)).orderBy(asc(rsvps.createdAt)),
database
.select()
.from(inviteTokens)
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
.limit(1)
]);
if (!eventData[0]) {
throw error(404, 'Event not found');
}
if (!tokenData[0]) {
throw error(404, 'Invalid invite token');
}
const event = eventData[0];
const eventRsvps = rsvpData;
const inviteToken = tokenData[0];
// Check if token is still valid
if (!isTokenValid(inviteToken.expiresAt.toISOString())) {
throw error(410, 'Invite token has expired');
}
// Check if event is invite-only
if (event.visibility !== 'invite-only') {
throw error(403, 'This event does not require an invite');
}
// Transform the data to match the expected interface
const transformedEvent = {
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,
attendee_limit: event.attendeeLimit,
visibility: event.visibility,
user_id: event.userId,
created_at: event.createdAt?.toISOString() || new Date().toISOString(),
updated_at: event.updatedAt?.toISOString() || new Date().toISOString()
};
const transformedRsvps = eventRsvps.map((rsvp) => ({
id: rsvp.id,
event_id: rsvp.eventId,
name: rsvp.name,
user_id: rsvp.userId,
created_at: rsvp.createdAt?.toISOString() || new Date().toISOString()
}));
const userId = cookies.get('cactoideUserId');
return {
event: transformedEvent,
rsvps: transformedRsvps,
userId: userId,
inviteToken: {
id: inviteToken.id,
event_id: inviteToken.eventId,
token: inviteToken.token,
expires_at: inviteToken.expiresAt.toISOString(),
created_at: inviteToken.createdAt?.toISOString() || new Date().toISOString()
}
};
} catch (err) {
if (err instanceof Response) throw err; // This is the 404/410/403 error
console.error('Error loading invite-only event:', err);
throw error(500, 'Failed to load event');
}
};
export const actions: Actions = {
addRSVP: async ({ request, params, cookies }) => {
const eventId = params.id;
const token = params.token;
const formData = await request.formData();
const name = formData.get('newAttendeeName') as string;
const numberOfGuests = parseInt(formData.get('numberOfGuests') as string) || 0;
const userId = cookies.get('cactoideUserId');
if (!name?.trim() || !userId) {
return fail(400, { error: 'Name and user ID are required' });
}
try {
// Verify the invite token is still valid
const [tokenData] = await database
.select()
.from(inviteTokens)
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
.limit(1);
if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) {
return fail(403, { error: 'Invalid or expired invite token' });
}
// Check if event exists and get its details
const [eventData] = await database.select().from(events).where(eq(events.id, eventId));
if (!eventData) {
return fail(404, { error: 'Event not found' });
}
// 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;
// Check if event is full (for limited type events)
if (eventData.type === 'limited' && eventData.attendeeLimit) {
if (totalAttendees > eventData.attendeeLimit) {
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.`
});
}
}
// Check if name is already in the list
if (currentRSVPs.some((rsvp) => rsvp.name.toLowerCase() === name.toLowerCase())) {
return fail(400, { error: 'Name already exists for this event' });
}
// Prepare RSVPs to insert
const rsvpsToInsert = [
{
eventId: eventId,
name: name.trim(),
userId: userId,
createdAt: new Date()
}
];
// Add guest entries
for (let i = 1; i <= numberOfGuests; i++) {
rsvpsToInsert.push({
eventId: eventId,
name: `${name.trim()}'s Guest #${i}`,
userId: userId,
createdAt: new Date()
});
}
// Insert all RSVPs
await database.insert(rsvps).values(rsvpsToInsert);
return { success: true, type: 'add' };
} catch (err) {
console.error('Error adding RSVP:', err);
return fail(500, { error: 'Failed to add RSVP' });
}
},
removeRSVP: async ({ request, params, cookies }) => {
const eventId = params.id;
const token = params.token;
const formData = await request.formData();
const rsvpId = formData.get('rsvpId') as string;
const userId = cookies.get('cactoideUserId');
if (!rsvpId || !userId) {
return fail(400, { error: 'RSVP ID and user ID are required' });
}
try {
// Verify the invite token is still valid
const [tokenData] = await database
.select()
.from(inviteTokens)
.where(and(eq(inviteTokens.eventId, eventId), eq(inviteTokens.token, token)))
.limit(1);
if (!tokenData || !isTokenValid(tokenData.expiresAt.toISOString())) {
return fail(403, { error: 'Invalid or expired invite token' });
}
// Check if RSVP exists and belongs to the user
const [rsvpData] = await database
.select()
.from(rsvps)
.where(and(eq(rsvps.id, rsvpId), eq(rsvps.eventId, eventId), eq(rsvps.userId, userId)))
.limit(1);
if (!rsvpData) {
return fail(404, { error: 'RSVP not found or you do not have permission to remove it' });
}
// Delete the RSVP
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
return { success: true, type: 'remove' };
} catch (err) {
console.error('Error removing RSVP:', err);
return fail(500, { error: 'Failed to remove RSVP' });
}
}
};

View File

@@ -0,0 +1,472 @@
<script lang="ts">
import { page } from '$app/stores';
import { browser } from '$app/environment';
import type { Event, RSVP, InviteToken } from '$lib/types';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { formatTime, formatDate } from '$lib/dateHelpers.js';
import CalendarModal from '$lib/components/CalendarModal.svelte';
import type { CalendarEvent } from '$lib/calendarHelpers.js';
import { t } from '$lib/i18n/i18n.js';
export let data: { event: Event; rsvps: RSVP[]; userId: string; inviteToken: InviteToken };
export let form;
let event: Event;
let rsvps: RSVP[] = [];
let newAttendeeName = '';
let isAddingRSVP = false;
let error = '';
let success = '';
let addGuests = false;
let numberOfGuests = 1;
let showCalendarModal = false;
let calendarEvent: CalendarEvent;
// Use server-side data
$: event = data.event;
$: rsvps = data.rsvps;
$: currentUserId = data.userId;
$: isEventCreator = event.user_id === currentUserId;
// Create calendar event object when event data changes
$: if (event && browser) {
calendarEvent = {
name: event.name,
date: event.date,
time: event.time,
location: event.location,
url: `${$page.url.origin}/event/${eventId}/invite/${token}`
};
}
// Handle form errors from server
$: if (form?.error) {
error = String(form.error);
success = '';
}
// Handle form success from server
$: if (form?.success) {
success = 'RSVP added successfully!';
error = '';
newAttendeeName = '';
addGuests = false;
numberOfGuests = 1;
}
const eventId = $page.params.id || '';
const token = $page.params.token || '';
const copyInviteLink = () => {
if (browser && isEventCreator) {
const url = `${$page.url.origin}/event/${eventId}/invite/${token}`;
navigator.clipboard.writeText(url).then(() => {
success = 'Invite link copied to clipboard!';
setTimeout(() => {
success = '';
}, 3000);
});
}
};
const clearMessages = () => {
error = '';
success = '';
};
// Calendar modal functions
const openCalendarModal = () => {
showCalendarModal = true;
};
const closeCalendarModal = () => {
showCalendarModal = false;
};
</script>
<svelte:head>
<title>{event?.name || t('event.eventTitle')} - Invite Only</title>
</svelte:head>
<div class="flex min-h-screen flex-col">
<!-- Main Content -->
<div class="container mx-auto flex-1 px-4 py-6">
{#if error && !event}
<!-- Error State -->
<div class="mx-auto max-w-md text-center">
<div class="rounded-sm border border-red-500/30 bg-red-900/20 p-8">
<div class="mb-4 text-6xl text-red-400">⚠️</div>
<h2 class="mb-4 text-2xl font-bold text-red-400">{t('event.eventNotFoundTitle')}</h2>
<p class="my-8">{t('event.eventNotFoundDescription')}</p>
<button
on:click={() => goto('/create')}
class="border-white-500 bg-white-400/20 mt-2 rounded-sm border px-6 py-3 font-semibold text-white duration-400 hover:scale-110 hover:bg-white/10"
>
{t('common.createNewEvent')}
</button>
</div>
</div>
{:else if event}
<div class="mx-auto max-w-2xl space-y-6">
<!-- Invite Only Banner -->
<div class="rounded-sm border border-amber-500/30 bg-amber-900/20 p-4">
<div class="flex items-center space-x-3">
<div class="text-2xl">🎫</div>
<div>
<h3 class="font-semibold text-amber-400">{t('event.inviteOnlyBannerTitle')}</h3>
<p class="text-sm text-amber-300">{t('event.inviteOnlyBannerSubtitle')}</p>
</div>
</div>
</div>
<!-- Event Details Card -->
<div class="rounded-sm border p-6 shadow-2xl">
<h2 class=" mb-4 text-center text-2xl font-bold">
{event.name}
</h2>
<div class="space-y-4">
<!-- Date & Time -->
<div class="flex items-center space-x-3 text-violet-400">
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
<div>
<p class="font-semibold text-white">
{formatDate(event.date)}
<span class="font-medium text-violet-400">-</span>
{formatTime(event.time)}
</p>
</div>
</div>
<!-- Location (only show when not 'none') -->
<div class="flex items-center space-x-3 text-violet-400">
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
></path>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
</div>
<div>
{#if event.location_type === 'none'}
<p class="font-semibold text-white">N/A</p>
{:else if event.location_type === 'maps' && event.location_url}
<a
href={event.location_url}
target="_blank"
rel="noopener noreferrer"
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
>
{t('create.locationMapsOption')}
</a>
{:else}
<p class="font-semibold text-white">{event.location}</p>
{/if}
</div>
</div>
<!-- Event Type, Visibility & Capacity -->
<div class="flex items-center justify-between rounded-sm p-3">
<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>
<span
class="rounded-sm border border-amber-300 px-2 py-1 text-xs font-medium text-amber-400"
>
{t('event.inviteOnlyBadge')}
</span>
</div>
{#if event.type === 'limited' && event.attendee_limit}
<div class="text-right">
<p class="text-sm">{t('common.capacity')}</p>
<p class=" text-lg font-bold">
{rsvps.length}/{event.attendee_limit}
</p>
</div>
{/if}
</div>
</div>
</div>
<!-- RSVP Form -->
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
{#if event.type === 'limited' && event.attendee_limit && rsvps.length >= event.attendee_limit}
<div class="py-6 text-center">
<div class="mb-3 text-4xl text-red-400">🚫</div>
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
<p class="mt-1 text-sm">{t('event.maximumCapacityReached')}</p>
</div>
{:else}
<form
method="POST"
action="?/addRSVP"
use:enhance={() => {
isAddingRSVP = true;
clearMessages();
return async ({ result, update }) => {
isAddingRSVP = false;
if (result.type === 'failure') {
error = String(result.data?.error || 'Failed to add RSVP');
}
update();
};
}}
class="space-y-4"
>
<input type="hidden" name="userId" value={currentUserId} />
<div>
<label for="attendeeName" class=" mb-2 block text-sm font-semibold">
{t('event.yourNameLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="attendeeName"
name="newAttendeeName"
type="text"
bind:value={newAttendeeName}
class="border-dark-300 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm"
placeholder={t('event.yourNamePlaceholder')}
maxlength="50"
required
/>
</div>
<!-- Add Guests Toggle -->
<div class="flex items-center space-x-3">
<input
id="addGuests"
type="checkbox"
bind:checked={addGuests}
class="h-4 w-4 rounded border-gray-300 text-violet-600 focus:ring-violet-500"
/>
<label for="addGuests" class="text-sm font-medium text-white">
{t('event.addGuestsLabel')}
</label>
</div>
<!-- Number of Guests Input -->
{#if addGuests}
<div>
<label for="numberOfGuests" class="mb-2 block text-sm font-semibold">
{t('event.numberOfGuestsLabel')}
<span class="text-red-400">{t('common.required')}</span>
</label>
<input
id="numberOfGuests"
name="numberOfGuests"
type="number"
bind:value={numberOfGuests}
min="1"
max="10"
class="border-dark-300 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm"
placeholder={t('event.numberOfGuestsPlaceholder')}
required
/>
<p class="mt-1 text-xs text-slate-400">
{t('event.guestsWillBeAddedAs', {
name: newAttendeeName || t('common.yourNamePlaceholder')
})}
</p>
</div>
{/if}
<button
type="submit"
disabled={isAddingRSVP ||
!newAttendeeName.trim() ||
(addGuests && numberOfGuests < 1)}
class=" hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{#if isAddingRSVP}
<div class="flex items-center justify-center">
<div
class="mr-2 h-5 w-5 animate-spin rounded-full border-b-2 border-white"
></div>
{t('event.adding')}
</div>
{:else if addGuests && numberOfGuests > 0}
{t('event.joinEventWithGuests', {
count: numberOfGuests,
plural: numberOfGuests > 1 ? 's' : ''
})}
{:else}
{t('event.joinEventButton')}
{/if}
</button>
</form>
{/if}
</div>
<!-- Attendees List -->
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
<div class="mb-4 flex items-center justify-between">
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
<span class="text-2xl font-bold">{rsvps.length}</span>
</div>
{#if rsvps.length === 0}
<div class="text-dark-400 py-8 text-center">
<p>{t('event.noAttendeesYet')}</p>
<p class="mt-1 text-sm">{t('event.beFirstToJoin')}</p>
</div>
{:else}
<div class="space-y-3">
{#each rsvps as attendee, i (i)}
<div
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
>
<div class="flex items-center space-x-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
"'s Guest"
)
? 'text-white-400 bg-violet-500/40'
: 'bg-violet-500/20 text-violet-400'}"
>
{attendee.name.charAt(0).toUpperCase()}
</div>
<div>
<p
class="font-medium text-white {attendee.name.includes("'s Guest")
? 'text-amber-300'
: ''}"
>
{attendee.name}
</p>
<p class="text-xs text-violet-400">
{(() => {
const date = new Date(attendee.created_at);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}/${month}/${day} ${hours}:${minutes}`;
})()}
</p>
</div>
</div>
{#if attendee.user_id === currentUserId}
<form
method="POST"
action="?/removeRSVP"
use:enhance={() => {
clearMessages();
return async ({ result, update }) => {
if (result.type === 'failure') {
error = String(result.data?.error || 'Failed to remove RSVP');
}
update();
};
}}
style="display: inline;"
>
<input type="hidden" name="rsvpId" value={attendee.id} />
<button
type="submit"
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
aria-label={t('event.removeRsvpAriaLabel')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</form>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Action Buttons -->
<div class="max-w-2xl space-y-3">
<button
on:click={copyInviteLink}
disabled={!isEventCreator}
class="w-full rounded-sm border-2 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105 {isEventCreator
? 'border-violet-500 bg-violet-400/20 hover:bg-violet-400/70'
: 'cursor-not-allowed border-gray-500 bg-gray-600/20 hover:bg-gray-600/30'}"
>
{t('event.copyInviteLinkButton')}
</button>
<button
on:click={openCalendarModal}
class="hover:bg-violet-400/70' w-full rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-3 py-4 font-bold font-medium font-semibold text-white shadow-lg transition-all duration-200 hover:scale-105"
>
{t('event.addToCalendarButton')}
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Calendar Modal -->
{#if calendarEvent && browser}
<CalendarModal
bind:isOpen={showCalendarModal}
event={calendarEvent}
{eventId}
baseUrl={$page.url.origin}
on:close={closeCalendarModal}
/>
{/if}
<!-- Success/Error Messages -->
{#if success}
{#if form?.type === 'add'}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-green-500/30 bg-green-900/20 p-4 text-green-400"
>
{success}
</div>
{:else if form?.type === 'remove'}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-yellow-500/30 bg-yellow-900/20 p-4 text-yellow-400"
>
{t('event.removedRsvpSuccessfully')}
</div>
{/if}
{/if}
{#if error}
<div
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900/20 p-4 text-red-400"
>
{error}
</div>
{/if}