Compare commits

...

24 Commits

Author SHA1 Message Date
Levente Orban
05556eefdb feat: add pino logger for serverside 2025-10-27 17:17:08 +01:00
Levente Orban
2273ae50a4 feat: add pino logger for serverside 2025-10-27 11:02:32 +01:00
Levente Orban
e75c7e40dc feat: add pino logger for serverside 2025-10-27 11:00:22 +01:00
Levente Orban
2a96d3762c feat: add pino logger for serverside 2025-10-27 11:00:17 +01:00
Levente Orban
935042dd06 feat: invite only event type implementations 2025-10-27 10:39:15 +01:00
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
Levente Orban
984c296725 fix: userId not generated in the first visit 2025-10-20 10:18:20 +02:00
Levente Orban
9acfa08ea8 chore(deps-dev): bump vite from 7.1.2 to 7.1.10 in the npm_and_yarn group across 1 directory 2025-10-20 08:49:24 +02:00
Levente Orban
45cb95f6a8 chore(deps): bump devalue from 5.1.1 to 5.4.1 in the npm_and_yarn group across 1 directory 2025-10-20 08:49:13 +02:00
dependabot[bot]
8426bd5704 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.2 to 7.1.10
- [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.10/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-19 07:00:04 +00:00
dependabot[bot]
b9833db3bb chore(deps): bump devalue in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [devalue](https://github.com/sveltejs/devalue).


Updates `devalue` from 5.1.1 to 5.4.1
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.1.1...v5.4.1)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.4.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-19 07:00:03 +00:00
Levente Orban
b3572293ba chore: add support me section to README.md 2025-10-15 15:54:42 +02:00
35 changed files with 1857 additions and 265 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

View File

@@ -62,6 +62,8 @@ jobs:
file: ./Dockerfile
build-args: |
PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }}
LOG_PRETTY=${{ vars.LOG_PRETTY }}
LOG_LEVEL=${{ vars.LOG_LEVEL }}
push: true
platforms: linux/amd64,linux/arm64
cache-from: type=gha

View File

@@ -30,6 +30,8 @@ jobs:
run: npm run build
env:
PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }}
LOG_PRETTY: ${{ vars.LOG_PRETTY }}
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
- name: Test build output
run: |

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'));

431
package-lock.json generated
View File

@@ -1,16 +1,18 @@
{
"name": "event-cactus",
"version": "0.0.1",
"name": "cactoide",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-cactus",
"version": "0.0.1",
"name": "cactoide",
"version": "0.1.1",
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"postgres": "^3.4.7"
},
"devDependencies": {
@@ -22,7 +24,12 @@
"@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",
@@ -35,7 +42,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
"vite": "^7.1.11"
}
},
"node_modules/@drizzle-team/brocli": {
@@ -1231,6 +1238,12 @@
"node": ">= 8"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1643,6 +1656,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 +1689,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",
@@ -1949,6 +1964,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@@ -2033,17 +2108,20 @@
"dev": true,
"license": "MIT"
},
<<<<<<< HEAD
=======
"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==",
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"devOptional": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.10.0"
"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",
@@ -2096,6 +2174,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",
@@ -2313,6 +2392,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"
},
@@ -2379,6 +2459,15 @@
"node": ">= 0.4"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2508,6 +2597,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"license": "MIT"
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -2558,6 +2653,15 @@
"node": ">=4"
}
},
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -2602,15 +2706,15 @@
}
},
"node_modules/devalue": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz",
"integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==",
"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": {
@@ -2748,6 +2852,15 @@
}
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@@ -2768,6 +2881,7 @@
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2835,6 +2949,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",
@@ -3055,6 +3170,12 @@
"node": ">=0.10.0"
}
},
"node_modules/fast-copy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3106,6 +3227,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
@@ -3304,6 +3431,12 @@
"node": ">= 0.4"
}
},
"node_modules/help-me": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"license": "MIT"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3421,6 +3554,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3846,6 +3988,15 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -3934,6 +4085,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4041,6 +4210,79 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pino": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^3.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-pretty": {
"version": "13.1.2",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.2.tgz",
"integrity": "sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==",
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
"dateformat": "^4.6.3",
"fast-copy": "^3.0.2",
"fast-safe-stringify": "^2.1.1",
"help-me": "^5.0.0",
"joycon": "^3.1.1",
"minimist": "^1.2.6",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^2.0.0",
"pump": "^3.0.0",
"secure-json-parse": "^4.0.0",
"sonic-boom": "^4.0.1",
"strip-json-comments": "^5.0.2"
},
"bin": {
"pino-pretty": "bin.js"
}
},
"node_modules/pino-pretty/node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"license": "MIT",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pino-std-serializers": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -4060,6 +4302,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4182,6 +4425,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"
},
@@ -4206,6 +4450,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -4222,6 +4467,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"
@@ -4314,6 +4560,32 @@
}
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -4345,6 +4617,12 @@
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -4359,6 +4637,15 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4415,6 +4702,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"
},
@@ -4485,6 +4773,31 @@
"node": ">=6"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@@ -4541,6 +4854,15 @@
"node": ">=18"
}
},
"node_modules/sonic-boom": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -4571,6 +4893,15 @@
"source-map": "^0.6.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4614,6 +4945,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",
@@ -4706,7 +5038,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",
@@ -4736,14 +5069,23 @@
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"node_modules/thread-stream": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"real-require": "^0.2.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -4806,6 +5148,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4839,12 +5182,11 @@
}
},
"node_modules/undici-types": {
"version": "7.10.0",
"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
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
@@ -4864,17 +5206,18 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
"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.4.6",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
@@ -4982,6 +5325,12 @@
"node": ">=0.10.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
@@ -4992,20 +5341,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,6 +22,7 @@
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.9.1",
"drizzle-kit": "^0.31.4",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -35,12 +36,14 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4"
"vite": "^7.1.11"
},
"dependencies": {
"@sveltejs/adapter-node": "^5.3.1",
"drizzle-orm": "^0.44.5",
"fuse.js": "^7.1.0",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"postgres": "^3.4.7"
}
}

44
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,44 @@
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { generateUserId } from '$lib/generateUserId.js';
import { ensureDatabaseConnection } from '$lib/database/healthCheck';
import { logger } from '$lib/logger';
// 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) {
logger.error({ error }, 'Database health check failed');
// 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();
const DAYS = 400; // practical upper bound in many browsers for cookies
const MAX_AGE = 60 * 60 * 24 * DAYS;
const PATH = '/';
if (!cactoideUserId) {
logger.debug({ userId }, 'No cactoideUserId cookie found, generating new one');
event.cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
} else {
logger.debug({ cactoideUserId }, 'cactoideUserId cookie found, using existing one');
}
return resolve(event);
};

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,108 @@
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
import { logger } from '$lib/logger';
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) {
logger.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;
logger.info({ maxRetries }, 'Starting database health check');
for (let attempt = 1; attempt <= maxRetries; attempt++) {
logger.debug({ 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();
logger.info({ attempt }, 'Database connection successful');
return {
success: true,
attempts: attempt
};
} catch (error) {
lastError = error as Error;
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
logger.warn({ attempt, error: errorMessage }, 'Database connection failed');
// Don't wait after the last attempt
if (attempt < maxRetries) {
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
logger.debug({ delay }, 'Waiting before retry');
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
const finalError = lastError?.message || 'Unknown database connection error';
logger.error(
{ attempts: maxRetries, error: finalError },
'All database connection attempts failed'
);
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) {
logger.fatal(
{ error: result.error, attempts: result.attempts },
'Database connection failed after all retry attempts. 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;
}

22
src/lib/logger.ts Normal file
View File

@@ -0,0 +1,22 @@
import pino from 'pino';
import { LOG_PRETTY, LOG_LEVEL } from '$env/static/private';
const USE_PRETTY_LOGS = LOG_PRETTY === 'true';
const transport = USE_PRETTY_LOGS
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname'
}
}
: undefined;
export const logger = pino({
level: LOG_LEVEL,
transport
});
export type Logger = typeof logger;

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,20 +1,5 @@
import { generateUserId } from '$lib/generateUserId.js';
export function load({ cookies }) {
const cactoideUserId = cookies.get('cactoideUserId');
const userId = generateUserId();
const DAYS = 400; // practical upper bound in many browsers for cookies
const MAX_AGE = 60 * 60 * 24 * DAYS;
const PATH = '/';
if (!cactoideUserId) {
console.debug(`There is no cactoideUserId cookie, generating new one...`);
cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
} else {
console.debug(`cactoideUserId: ${cactoideUserId}`);
console.debug(`cactoideUserId cookie found, using existing one...`);
}
return {
cactoideUserId

View File

@@ -1,7 +1,9 @@
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';
import { logger } from '$lib/logger';
// Generate a random URL-friendly ID
function generateEventId(): string {
@@ -25,7 +27,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 +58,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 +100,7 @@ export const actions: Actions = {
const eventId = generateEventId();
// Create the event
await database
.insert(events)
.values({
@@ -105,13 +114,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);
logger.error({ error, eventId, userId }, 'Unexpected error creating event');
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,16 @@
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';
import { logger } from '$lib/logger';
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
@@ -33,7 +34,7 @@ export const load: PageServerLoad = async () => {
events: transformedEvents
};
} catch (error) {
console.error('Error loading public events:', error);
logger.error({ error }, 'Error loading public events');
// Return empty array on error to prevent page crash
return {

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

@@ -3,6 +3,7 @@ import { events } from '$lib/database/schema';
import { fail } from '@sveltejs/kit';
import { eq, desc } from 'drizzle-orm';
import type { Actions } from './$types';
import { logger } from '$lib/logger';
export const load = async ({ cookies }) => {
const userId = cookies.get('cactoideUserId');
@@ -36,7 +37,7 @@ export const load = async ({ cookies }) => {
return { events: transformedEvents };
} catch (error) {
console.error('Error loading user events:', error);
logger.error({ error, userId }, 'Error loading user events');
return { events: [] };
}
};
@@ -68,7 +69,7 @@ export const actions: Actions = {
return { success: true };
} catch (error) {
console.error('Error deleting event:', error);
logger.error({ error, eventId, userId }, 'Error deleting event');
return fail(500, { error: 'Failed to delete event' });
}
}

View File

@@ -3,6 +3,7 @@ import { events, rsvps } from '$lib/database/schema';
import { eq, asc } from 'drizzle-orm';
import { error, fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { logger } from '$lib/logger';
export const load: PageServerLoad = async ({ params, cookies }) => {
const eventId = params.id;
@@ -25,6 +26,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,
@@ -60,7 +71,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
} catch (err) {
if (err instanceof Response) throw err; // This is the 404 error
console.error('Error loading event:', err);
logger.error({ error: err, eventId }, 'Error loading event');
throw error(500, 'Failed to load event');
}
};
@@ -85,6 +96,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));
@@ -130,7 +146,7 @@ export const actions: Actions = {
return { success: true, type: 'add' };
} catch (err) {
console.error('Error adding RSVP:', err);
logger.error({ error: err, eventId, userId, name }, 'Error adding RSVP');
return fail(500, { error: 'Failed to add RSVP' });
}
},
@@ -148,7 +164,7 @@ export const actions: Actions = {
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
return { success: true, type: 'remove' };
} catch (err) {
console.error('Error removing RSVP:', err);
logger.error({ error: err, rsvpId }, 'Error removing RSVP');
return fail(500, { error: 'Failed to remove RSVP' });
}
}

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,8 +1,9 @@
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';
import { logger } from '$lib/logger';
export const load: PageServerLoad = async ({ params, cookies }) => {
const eventId = params.id;
@@ -23,8 +24,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 +109,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);
@@ -142,7 +166,7 @@ export const actions: Actions = {
})
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
.catch((error) => {
console.error('Unexpected error updating event', error);
logger.error({ error, eventId, userId }, 'Unexpected error updating event');
throw error;
});

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}

View File

@@ -9,6 +9,7 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"types": ["node"]
}
}