mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-22 22:25:29 +00:00
Compare commits
21 Commits
fix/broken
...
feat/invit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5809cb49ee | ||
|
|
93b0bac48a | ||
|
|
c9c78d0ea6 | ||
|
|
f6b51232a7 | ||
|
|
bb573c603a | ||
|
|
75fa7a9528 | ||
|
|
eb5543fb9b | ||
|
|
706e6cf712 | ||
|
|
c6decaa0d1 | ||
|
|
d193283b11 | ||
|
|
accfd540f0 | ||
|
|
9b1ef64618 | ||
|
|
c340088434 | ||
|
|
984c296725 | ||
|
|
9acfa08ea8 | ||
|
|
45cb95f6a8 | ||
|
|
8426bd5704 | ||
|
|
b9833db3bb | ||
|
|
b3572293ba | ||
|
|
c1752efe4b | ||
|
|
491d0020bd |
13
.env.docker.example
Normal file
13
.env.docker.example
Normal 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
|
||||||
@@ -4,14 +4,10 @@ POSTGRES_USER=cactoide
|
|||||||
POSTGRES_PASSWORD=cactoide_password
|
POSTGRES_PASSWORD=cactoide_password
|
||||||
POSTGRES_PORT=5432
|
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
|
# Application configuration
|
||||||
|
DATABASE_URL="postgres://cactoide:cactoide_password@localhost:5432/cactoide_database"
|
||||||
APP_VERSION=latest
|
APP_VERSION=latest
|
||||||
PORT=3000
|
PORT=5173
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
PUBLIC_LANDING_INFO=true
|
PUBLIC_LANDING_INFO=true
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ Thumbs.db
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
!.env.docker.example
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
|
|||||||
92
Makefile
92
Makefile
@@ -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:
|
help:
|
||||||
@echo "Cactoide Commands"
|
@echo "Available commands:"
|
||||||
@echo ""
|
@echo " build - Docker build the application"
|
||||||
@echo "Main commands:"
|
@echo " up - Start all services"
|
||||||
@echo " make build - Build the Docker images"
|
@echo " down - Stop all services"
|
||||||
@echo " make up - Start all services (database + app)"
|
@echo " db-only - Start only the database"
|
||||||
@echo ""
|
@echo " logs - Show logs from all services"
|
||||||
@echo "Individual services:"
|
@echo " db-clean - Clean up all Docker resources"
|
||||||
@echo " make db-only - Start only the database"
|
@echo " prune - Clean up everything (containers, images, volumes)"
|
||||||
@echo ""
|
@echo " i18n - Validate translation files"
|
||||||
@echo "Utility commands:"
|
@echo " lint - Lint the project"
|
||||||
@echo " make logs - Show logs from all services"
|
@echo " format - Format the project"
|
||||||
@echo " make db-clean - Stop & remove database container"
|
@echo " migrate-up - Apply invite-only events migration"
|
||||||
@echo " make prune - Remove all containers, images, and volumes"
|
@echo " migrate-down - Rollback invite-only events migration"
|
||||||
@echo " make i18n - Validate translation files against messages.json"
|
|
||||||
@echo " make help - Show this help message"
|
# 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 the Docker images
|
||||||
build:
|
build:
|
||||||
@@ -28,6 +60,14 @@ up:
|
|||||||
@echo "Starting all services..."
|
@echo "Starting all services..."
|
||||||
docker compose up -d
|
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
|
# Start only the database
|
||||||
db-only:
|
db-only:
|
||||||
@echo "Starting only the database..."
|
@echo "Starting only the database..."
|
||||||
@@ -38,23 +78,13 @@ logs:
|
|||||||
@echo "Showing logs from all services..."
|
@echo "Showing logs from all services..."
|
||||||
docker compose logs -f
|
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)
|
# Clean up everything (containers, images, volumes)
|
||||||
prune:
|
prune:
|
||||||
@echo "Cleaning up all Docker resources..."
|
@echo "Cleaning up all Docker resources..."
|
||||||
docker compose down -v --rmi all
|
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:
|
lint:
|
||||||
@echo "Linting the project..."
|
@echo "Linting the project..."
|
||||||
@@ -64,4 +94,8 @@ format:
|
|||||||
@echo "Formatting the project..."
|
@echo "Formatting the project..."
|
||||||
npm run format
|
npm run format
|
||||||
|
|
||||||
|
#TODO: not working yet
|
||||||
|
i18n:
|
||||||
|
@echo "Validating translation files..."
|
||||||
|
@if [ -n "$(FILE)" ]; then \
|
||||||
|
./scripts/i18n-check.sh $(FILE); \
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -37,7 +37,7 @@ Uses the [`docker-compose.yml`](docker-compose.yml) file to setup the applicatio
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/polaroi8d/cactoide/
|
git clone https://github.com/polaroi8d/cactoide/
|
||||||
cd cactoide
|
cd cactoide
|
||||||
cp env.example .env
|
cp .env.docker.example .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ docker compose up -d
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/polaroi8d/cactoide/
|
git clone https://github.com/polaroi8d/cactoide/
|
||||||
cd cactoide
|
cd cactoide
|
||||||
cp env.example .env
|
cp .env.example .env
|
||||||
make db-only
|
make db-only
|
||||||
npm run dev -- --open
|
npm run dev -- --open
|
||||||
```
|
```
|
||||||
@@ -82,6 +82,19 @@ make i18n
|
|||||||
make i18n FILE=src/lib/i18n/it.json
|
make i18n FILE=src/lib/i18n/it.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Support
|
||||||
|
|
||||||
|
Cactoide is an open-source project licensed under `AGPL-3.0`. Its growth and development are possible thanks to the amazing support of the community. This project is the result of many late nights, weekends, and after-hours work.
|
||||||
|
|
||||||
|
It isn’t backed by a big company. Development depends on the support and generosity of people like you. With your help, I can focus more on making Cactoide even better and building tools that make coding more enjoyable.
|
||||||
|
|
||||||
|
You can support in a few ways:
|
||||||
|
|
||||||
|
- Send a one-time donation via [paypal.me/zenoazurben](paypal.me/zenoazurben)
|
||||||
|
- Reach me directly: leventeorb[@]gmail.com
|
||||||
|
|
||||||
|
If you enjoy using Cactoide, or if your business depends on it, please consider sponsoring its development. Your support keeps the project alive, improves it for everyone, and helps create educational content like blog posts and videos for the whole Cactoide community.
|
||||||
|
|
||||||
### License
|
### License
|
||||||
|
|
||||||
This project is licensed under the `AGPL-3.0 License` - see the [LICENSE](./LICENSE) file for details.
|
This project is licensed under the `AGPL-3.0 License` - see the [LICENSE](./LICENSE) file for details.
|
||||||
|
|||||||
@@ -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_event_id ON rsvps(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
||||||
|
|
||||||
-- =======================================
|
|
||||||
-- 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;
|
COMMIT;
|
||||||
25
database/migrations/20241220_001_add_invite_only_events.sql
Normal file
25
database/migrations/20241220_001_add_invite_only_events.sql
Normal 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);
|
||||||
@@ -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'));
|
||||||
149
package-lock.json
generated
149
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "event-cactus",
|
"name": "cactoide",
|
||||||
"version": "0.0.1",
|
"version": "0.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "event-cactus",
|
"name": "cactoide",
|
||||||
"version": "0.0.1",
|
"version": "0.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.3.1",
|
"@sveltejs/adapter-node": "^5.3.1",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.5",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.11"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
@@ -1643,6 +1643,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.30.1.tgz",
|
||||||
"integrity": "sha512-LyRpQmokZdMK4QOlGBbLX12c37IRnvC3rE6ysA4gLmBWMx5mheeEEjkZZXhtIL9Lze0BgMttaALFoROTx+kbEw==",
|
"integrity": "sha512-LyRpQmokZdMK4QOlGBbLX12c37IRnvC3rE6ysA4gLmBWMx5mheeEEjkZZXhtIL9Lze0BgMttaALFoROTx+kbEw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.0.0",
|
"@standard-schema/spec": "^1.0.0",
|
||||||
"@sveltejs/acorn-typescript": "^1.0.5",
|
"@sveltejs/acorn-typescript": "^1.0.5",
|
||||||
@@ -1675,6 +1676,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.1.2.tgz",
|
||||||
"integrity": "sha512-7v+7OkUYelC2dhhYDAgX1qO2LcGscZ18Hi5kKzJQq7tQeXpH215dd0+J/HnX2zM5B3QKcIrTVqCGkZXAy5awYw==",
|
"integrity": "sha512-7v+7OkUYelC2dhhYDAgX1qO2LcGscZ18Hi5kKzJQq7tQeXpH215dd0+J/HnX2zM5B3QKcIrTVqCGkZXAy5awYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
|
||||||
"debug": "^4.4.1",
|
"debug": "^4.4.1",
|
||||||
@@ -1949,6 +1951,66 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||||
@@ -2033,17 +2095,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "24.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
|
||||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~7.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
@@ -2096,6 +2147,7 @@
|
|||||||
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
|
"integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.39.1",
|
"@typescript-eslint/scope-manager": "8.39.1",
|
||||||
"@typescript-eslint/types": "8.39.1",
|
"@typescript-eslint/types": "8.39.1",
|
||||||
@@ -2313,6 +2365,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2602,15 +2655,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.1.1",
|
"version": "5.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.1.tgz",
|
||||||
"integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==",
|
"integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.4",
|
"version": "0.31.5",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.4.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz",
|
||||||
"integrity": "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA==",
|
"integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2768,6 +2821,7 @@
|
|||||||
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
"integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
@@ -2835,6 +2889,7 @@
|
|||||||
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
|
"integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4060,6 +4115,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -4182,6 +4238,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz",
|
||||||
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
"integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4206,6 +4263,7 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4222,6 +4280,7 @@
|
|||||||
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
|
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
|
||||||
@@ -4415,6 +4474,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
|
||||||
"integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
|
"integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4614,6 +4674,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.1.tgz",
|
||||||
"integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==",
|
"integrity": "sha512-fO6CLDfJYWHgfo6lQwkQU2vhCiHc2MBl6s3vEhK+sSZru17YL4R5s1v14ndRpqKAIkq8nCz6MTk1yZbESZWeyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/remapping": "^2.3.4",
|
"@jridgewell/remapping": "^2.3.4",
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -4706,7 +4767,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.12.tgz",
|
||||||
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
"integrity": "sha512-DzFtxOi+7NsFf7DBtI3BJsynR+0Yp6etH+nRPTbpWnS2pZBaSksv/JGctNwSWzbFjp0vxSqknaUylseZqMDGrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
@@ -4737,13 +4799,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.14",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.2"
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -4806,6 +4868,7 @@
|
|||||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -4843,8 +4906,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
@@ -4864,17 +4926,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
|
||||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.6",
|
"fdir": "^6.5.0",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"rollup": "^4.43.0",
|
"rollup": "^4.43.0",
|
||||||
"tinyglobby": "^0.2.14"
|
"tinyglobby": "^0.2.15"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -4992,20 +5055,6 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.5",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.20.0",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.1.11"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.3.1",
|
"@sveltejs/adapter-node": "^5.3.1",
|
||||||
|
|||||||
44
src/hooks.server.ts
Normal file
44
src/hooks.server.ts
Normal 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';
|
||||||
|
|
||||||
|
// Global flag to track if database health check has been performed
|
||||||
|
let dbHealthCheckPerformed = false;
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// Perform database health check only once during application startup
|
||||||
|
if (!dbHealthCheckPerformed) {
|
||||||
|
try {
|
||||||
|
await ensureDatabaseConnection({
|
||||||
|
maxRetries: 3,
|
||||||
|
baseDelay: 1000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
dbHealthCheckPerformed = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database health check failed:', error);
|
||||||
|
// The ensureDatabaseConnection function will exit the process
|
||||||
|
// if the database is unavailable, so this catch is just for safety
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cactoideUserId = event.cookies.get('cactoideUserId');
|
||||||
|
const userId = generateUserId();
|
||||||
|
|
||||||
|
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...`);
|
||||||
|
event.cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
|
||||||
|
} else {
|
||||||
|
console.debug(`cactoideUserId: ${cactoideUserId}`);
|
||||||
|
console.debug(`cactoideUserId cookie found, using existing one...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -15,7 +15,10 @@ export interface CalendarEvent {
|
|||||||
* Formats a date and time string for iCal format (UTC)
|
* Formats a date and time string for iCal format (UTC)
|
||||||
*/
|
*/
|
||||||
export const formatDateForICal = (date: string, time: string): string => {
|
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';
|
return eventDate.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
src/lib/database/healthCheck.ts
Normal file
104
src/lib/database/healthCheck.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
interface HealthCheckOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
baseDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthCheckResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
attempts: number;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a database health check with retry logic and exponential backoff
|
||||||
|
*/
|
||||||
|
export async function checkDatabaseHealth(
|
||||||
|
options: HealthCheckOptions = {}
|
||||||
|
): Promise<HealthCheckResult> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
baseDelay = 1000, // 1 second
|
||||||
|
maxDelay = 10000, // 10 seconds
|
||||||
|
timeout = 5000 // 5 seconds
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) {
|
||||||
|
console.error('DATABASE_URL environment variable is not set');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'DATABASE_URL environment variable is not set',
|
||||||
|
attempts: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
console.log(`Starting database health check (max retries: ${maxRetries})`);
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
console.log(`Attempt ${attempt}/${maxRetries} - Testing database connection`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new connection for the health check
|
||||||
|
const client = postgres(env.DATABASE_URL, {
|
||||||
|
max: 1,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: timeout / 1000, // Convert to seconds
|
||||||
|
onnotice: () => {} // Suppress notices
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the connection with a simple query
|
||||||
|
await client`SELECT 1 as health_check`;
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
console.log(`Database connection successful on attempt ${attempt}.`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
attempts: attempt
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.error(`Connection failed on attempt ${attempt}: ${errorMessage}`);
|
||||||
|
|
||||||
|
// Don't wait after the last attempt
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||||
|
console.log(`Waiting ${delay}ms before retry...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalError = lastError?.message || 'Unknown database connection error';
|
||||||
|
|
||||||
|
console.error(`All ${maxRetries} attempts failed. Final error: ${finalError}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: finalError,
|
||||||
|
attempts: maxRetries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs database health check and exits the process if it fails
|
||||||
|
*/
|
||||||
|
export async function ensureDatabaseConnection(options?: HealthCheckOptions): Promise<void> {
|
||||||
|
const result = await checkDatabaseHealth(options);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Database connection failed after all retry attempts');
|
||||||
|
console.error(`Error: ${result.error}`);
|
||||||
|
console.error(`Attempts made: ${result.attempts}`);
|
||||||
|
console.error('Exiting application...');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm';
|
|||||||
|
|
||||||
// --- Enums (matching the SQL CHECK constraints)
|
// --- Enums (matching the SQL CHECK constraints)
|
||||||
export const eventTypeEnum = pgEnum('event_type', ['limited', 'unlimited']);
|
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']);
|
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
|
||||||
|
|
||||||
// --- Events table
|
// --- 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)
|
// --- Relations (optional but handy for type safety)
|
||||||
import { relations } from 'drizzle-orm';
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
export const eventsRelations = relations(events, ({ many }) => ({
|
export const eventsRelations = relations(events, ({ many }) => ({
|
||||||
rsvps: many(rsvps)
|
rsvps: many(rsvps),
|
||||||
|
inviteTokens: many(inviteTokens)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const rsvpsRelations = relations(rsvps, ({ one }) => ({
|
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
|
// --- Inferred types for use in the application
|
||||||
export type Event = InferSelectModel<typeof events>;
|
export type Event = InferSelectModel<typeof events>;
|
||||||
export type NewEvent = InferInsertModel<typeof events>;
|
export type NewEvent = InferInsertModel<typeof events>;
|
||||||
export type Rsvp = InferSelectModel<typeof rsvps>;
|
export type Rsvp = InferSelectModel<typeof rsvps>;
|
||||||
export type NewRsvp = InferInsertModel<typeof rsvps>;
|
export type NewRsvp = InferInsertModel<typeof rsvps>;
|
||||||
|
export type InviteToken = InferSelectModel<typeof inviteTokens>;
|
||||||
|
export type NewInviteToken = InferInsertModel<typeof inviteTokens>;
|
||||||
|
|
||||||
// --- Additional utility types
|
// --- Additional utility types
|
||||||
export type EventWithRsvps = Event & {
|
export type EventWithRsvps = Event & {
|
||||||
rsvps: Rsvp[];
|
rsvps: Rsvp[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EventWithInviteTokens = Event & {
|
||||||
|
inviteTokens: InviteToken[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateEventData = Omit<NewEvent, 'id' | 'createdAt' | 'updatedAt'>;
|
export type CreateEventData = Omit<NewEvent, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
export type CreateRsvpData = Omit<NewRsvp, 'id' | 'createdAt'>;
|
export type CreateRsvpData = Omit<NewRsvp, 'id' | 'createdAt'>;
|
||||||
|
export type CreateInviteTokenData = Omit<NewInviteToken, 'id' | 'createdAt'>;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { Event } from './types';
|
import type { Event } from './types';
|
||||||
|
|
||||||
export const formatDate = (dateString: string): string => {
|
export const formatDate = (dateString: string): string => {
|
||||||
const date = new Date(dateString);
|
// Parse the date string as local date to avoid timezone issues
|
||||||
const year = date.getFullYear();
|
// Split the date string and create a Date object in local timezone
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const [year, month, day] = dateString.split('-').map(Number);
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const date = new Date(year, month - 1, day); // month is 0-indexed in Date constructor
|
||||||
return `${year}/${month}/${day}`;
|
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 => {
|
export const formatTime = (timeString: string): string => {
|
||||||
@@ -17,7 +20,10 @@ export const formatTime = (timeString: string): string => {
|
|||||||
export const isEventInTimeRange = (event: Event, timeFilter: string): boolean => {
|
export const isEventInTimeRange = (event: Event, timeFilter: string): boolean => {
|
||||||
if (timeFilter === 'any') return true;
|
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();
|
const now = new Date();
|
||||||
|
|
||||||
// Handle temporal status filters
|
// Handle temporal status filters
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"numberOfGuests": "Numero di Ospiti",
|
"numberOfGuests": "Numero di Ospiti",
|
||||||
"addGuests": "Aggiungi ospiti",
|
"addGuests": "Aggiungi ospiti",
|
||||||
"joinEvent": "Partecipa all'Evento",
|
"joinEvent": "Partecipa all'Evento",
|
||||||
"copyLink": "Copia Link",
|
|
||||||
"addToCalendar": "Aggiungi al Calendario",
|
"addToCalendar": "Aggiungi al Calendario",
|
||||||
"close": "Chiudi",
|
"close": "Chiudi",
|
||||||
"closeModal": "Chiudi finestra",
|
"closeModal": "Chiudi finestra",
|
||||||
@@ -64,7 +63,6 @@
|
|||||||
"eventNotFound": "Evento Non Trovato",
|
"eventNotFound": "Evento Non Trovato",
|
||||||
"eventIsFull": "L'Evento è Pieno!",
|
"eventIsFull": "L'Evento è Pieno!",
|
||||||
"maximumCapacityReached": "Raggiunta la capacità massima",
|
"maximumCapacityReached": "Raggiunta la capacità massima",
|
||||||
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
|
|
||||||
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
||||||
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
||||||
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
|
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
|
||||||
@@ -188,8 +186,10 @@
|
|||||||
"noAttendeesYet": "Ancora nessun partecipante",
|
"noAttendeesYet": "Ancora nessun partecipante",
|
||||||
"beFirstToJoin": "Sii il primo a partecipare!",
|
"beFirstToJoin": "Sii il primo a partecipare!",
|
||||||
"copyLinkButton": "Copia Link",
|
"copyLinkButton": "Copia Link",
|
||||||
|
"copyInviteLinkButton": "Copia Link Invito",
|
||||||
"addToCalendarButton": "Aggiungi al Calendario",
|
"addToCalendarButton": "Aggiungi al Calendario",
|
||||||
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
|
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
|
||||||
|
"inviteLinkCopied": "Link invito copiato negli appunti!",
|
||||||
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
||||||
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
||||||
"failedToAddRsvp": "Impossibile aggiungere RSVP",
|
"failedToAddRsvp": "Impossibile aggiungere RSVP",
|
||||||
@@ -208,7 +208,8 @@
|
|||||||
"viewEventAriaLabel": "Visualizza evento",
|
"viewEventAriaLabel": "Visualizza evento",
|
||||||
"editEventAriaLabel": "Modifica evento",
|
"editEventAriaLabel": "Modifica evento",
|
||||||
"deleteEventAriaLabel": "Elimina evento",
|
"deleteEventAriaLabel": "Elimina evento",
|
||||||
"removeRsvpAriaLabel": "Rimuovi RSVP"
|
"removeRsvpAriaLabel": "Rimuovi RSVP",
|
||||||
|
"inviteLinkExpiresAt": "Questo link scade quando l'evento inizia: {time}"
|
||||||
},
|
},
|
||||||
"discover": {
|
"discover": {
|
||||||
"title": "Scopri Eventi - Cactoide",
|
"title": "Scopri Eventi - Cactoide",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
|
"inviteOnly": "Invite Only",
|
||||||
"limited": "Limited",
|
"limited": "Limited",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"capacity": "Capacity",
|
"capacity": "Capacity",
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
"numberOfGuests": "Number of Guests",
|
"numberOfGuests": "Number of Guests",
|
||||||
"addGuests": "Add guest users",
|
"addGuests": "Add guest users",
|
||||||
"joinEvent": "Join Event",
|
"joinEvent": "Join Event",
|
||||||
"copyLink": "Copy Link",
|
|
||||||
"addToCalendar": "Add to Calendar",
|
"addToCalendar": "Add to Calendar",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"closeModal": "Close modal",
|
"closeModal": "Close modal",
|
||||||
@@ -64,9 +64,11 @@
|
|||||||
"eventNotFound": "Event Not Found",
|
"eventNotFound": "Event Not Found",
|
||||||
"eventIsFull": "Event is Full!",
|
"eventIsFull": "Event is Full!",
|
||||||
"maximumCapacityReached": "Maximum capacity reached",
|
"maximumCapacityReached": "Maximum capacity reached",
|
||||||
"eventLinkCopied": "Event link copied to clipboard!",
|
|
||||||
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
||||||
"removedRsvpSuccessfully": "Removed RSVP 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.",
|
"anUnexpectedErrorOccurred": "An unexpected error occurred.",
|
||||||
"somethingWentWrong": "Something went wrong. Please try again.",
|
"somethingWentWrong": "Something went wrong. Please try again.",
|
||||||
"failedToAddRsvp": "Failed to add RSVP",
|
"failedToAddRsvp": "Failed to add RSVP",
|
||||||
@@ -160,8 +162,10 @@
|
|||||||
"visibilityLabel": "Visibility",
|
"visibilityLabel": "Visibility",
|
||||||
"publicOption": "🌍 Public",
|
"publicOption": "🌍 Public",
|
||||||
"privateOption": "🔒 Private",
|
"privateOption": "🔒 Private",
|
||||||
|
"inviteOnlyOption": "🚧 Invite Only",
|
||||||
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
|
"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.",
|
"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...",
|
"creatingEvent": "Creating Event...",
|
||||||
"createEventButton": "Create Event"
|
"createEventButton": "Create Event"
|
||||||
},
|
},
|
||||||
@@ -188,9 +192,14 @@
|
|||||||
"noAttendeesYet": "No attendees yet",
|
"noAttendeesYet": "No attendees yet",
|
||||||
"beFirstToJoin": "Be the first to join!",
|
"beFirstToJoin": "Be the first to join!",
|
||||||
"copyLinkButton": "Copy Link",
|
"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",
|
"addToCalendarButton": "Add to Calendar",
|
||||||
"eventLinkCopied": "Event link copied to clipboard!",
|
"eventLinkCopied": "Event link copied to clipboard.",
|
||||||
"rsvpAddedSuccessfully": "RSVP added successfully!",
|
"inviteLinkCopied": "Invite link copied to clipboard.",
|
||||||
|
"rsvpAddedSuccessfully": "RSVP added successfully.",
|
||||||
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
"removedRsvpSuccessfully": "Removed RSVP successfully.",
|
||||||
"failedToAddRsvp": "Failed to add RSVP",
|
"failedToAddRsvp": "Failed to add RSVP",
|
||||||
"failedToRemoveRsvp": "Failed to remove RSVP",
|
"failedToRemoveRsvp": "Failed to remove RSVP",
|
||||||
@@ -208,7 +217,8 @@
|
|||||||
"viewEventAriaLabel": "View event",
|
"viewEventAriaLabel": "View event",
|
||||||
"editEventAriaLabel": "Edit event",
|
"editEventAriaLabel": "Edit event",
|
||||||
"deleteEventAriaLabel": "Delete event",
|
"deleteEventAriaLabel": "Delete event",
|
||||||
"removeRsvpAriaLabel": "Remove RSVP"
|
"removeRsvpAriaLabel": "Remove RSVP",
|
||||||
|
"inviteLinkExpiresAt": "This link expires when the event starts: {time}"
|
||||||
},
|
},
|
||||||
"discover": {
|
"discover": {
|
||||||
"title": "Discover Events - Cactoide",
|
"title": "Discover Events - Cactoide",
|
||||||
|
|||||||
33
src/lib/inviteTokenHelpers.ts
Normal file
33
src/lib/inviteTokenHelpers.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export type EventType = 'limited' | 'unlimited';
|
export type EventType = 'limited' | 'unlimited';
|
||||||
export type EventVisibility = 'public' | 'private';
|
export type EventVisibility = 'public' | 'private' | 'invite-only';
|
||||||
export type ActionType = 'add' | 'remove';
|
export type ActionType = 'add' | 'remove';
|
||||||
export type LocationType = 'none' | 'text' | 'maps';
|
export type LocationType = 'none' | 'text' | 'maps';
|
||||||
|
|
||||||
@@ -62,3 +62,11 @@ export interface DatabaseRSVP {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InviteToken {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
token: string;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
import { generateUserId } from '$lib/generateUserId.js';
|
|
||||||
|
|
||||||
export function load({ cookies }) {
|
export function load({ cookies }) {
|
||||||
const cactoideUserId = cookies.get('cactoideUserId');
|
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 {
|
return {
|
||||||
cactoideUserId
|
cactoideUserId
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { database } from '$lib/database/db';
|
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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
import { generateInviteToken, calculateTokenExpiration } from '$lib/inviteTokenHelpers.js';
|
||||||
|
|
||||||
// Generate a random URL-friendly ID
|
// Generate a random URL-friendly ID
|
||||||
function generateEventId(): string {
|
function generateEventId(): string {
|
||||||
@@ -25,7 +26,7 @@ export const actions: Actions = {
|
|||||||
const locationUrl = formData.get('location_url') as string;
|
const locationUrl = formData.get('location_url') as string;
|
||||||
const type = formData.get('type') as 'limited' | 'unlimited';
|
const type = formData.get('type') as 'limited' | 'unlimited';
|
||||||
const attendeeLimit = formData.get('attendee_limit') as string;
|
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');
|
const userId = cookies.get('cactoideUserId');
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
@@ -56,7 +57,13 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date(date) < new Date()) {
|
// Check if date is in the past using local timezone
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
const eventDate = new Date(year, month - 1, day);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (eventDate < today) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: 'Date cannot be in the past.',
|
error: 'Date cannot be in the past.',
|
||||||
values: {
|
values: {
|
||||||
@@ -92,6 +99,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const eventId = generateEventId();
|
const eventId = generateEventId();
|
||||||
|
|
||||||
|
// Create the event
|
||||||
await database
|
await database
|
||||||
.insert(events)
|
.insert(events)
|
||||||
.values({
|
.values({
|
||||||
@@ -105,13 +113,31 @@ export const actions: Actions = {
|
|||||||
type: type,
|
type: type,
|
||||||
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
|
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
userId: userId
|
userId: userId!
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Unexpected error', error);
|
console.error('Unexpected error', error);
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generate invite token for invite-only events
|
||||||
|
if (visibility === 'invite-only') {
|
||||||
|
const token = generateInviteToken();
|
||||||
|
const expiresAt = calculateTokenExpiration(date, time);
|
||||||
|
|
||||||
|
await database
|
||||||
|
.insert(inviteTokens)
|
||||||
|
.values({
|
||||||
|
eventId: eventId,
|
||||||
|
token: token,
|
||||||
|
expiresAt: new Date(expiresAt)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error creating invite token', error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw redirect(303, `/event/${eventId}`);
|
throw redirect(303, `/event/${eventId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
location_url: '',
|
location_url: '',
|
||||||
type: 'unlimited',
|
type: 'unlimited',
|
||||||
attendee_limit: undefined,
|
attendee_limit: undefined,
|
||||||
visibility: 'public'
|
visibility: 'public' as 'public' | 'private' | 'invite-only'
|
||||||
};
|
};
|
||||||
|
|
||||||
let errors: Record<string, string> = {};
|
let errors: Record<string, string> = {};
|
||||||
@@ -317,13 +317,13 @@
|
|||||||
{t('create.visibilityLabel')}
|
{t('create.visibilityLabel')}
|
||||||
<span class="text-red-400">{t('common.required')}</span>
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
</legend>
|
</legend>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
'public'
|
'public'
|
||||||
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
? ' 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')}
|
on:click={() => (eventData.visibility = 'public')}
|
||||||
>
|
>
|
||||||
{t('create.publicOption')}
|
{t('create.publicOption')}
|
||||||
@@ -338,11 +338,23 @@
|
|||||||
>
|
>
|
||||||
{t('create.privateOption')}
|
{t('create.privateOption')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400 italic">
|
<p class="mt-2 text-xs text-slate-400 italic">
|
||||||
{eventData.visibility === 'public'
|
{eventData.visibility === 'public'
|
||||||
? t('create.publicDescription')
|
? t('create.publicDescription')
|
||||||
: t('create.privateDescription')}
|
: eventData.visibility === 'private'
|
||||||
|
? t('create.privateDescription')
|
||||||
|
: 'Event is public but requires a special invite link to attend'}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { database } from '$lib/database/db';
|
import { database } from '$lib/database/db';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { desc, inArray } from 'drizzle-orm';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { events } from '$lib/database/schema';
|
import { events } from '$lib/database/schema';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
try {
|
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
|
const publicEvents = await database
|
||||||
.select()
|
.select()
|
||||||
.from(events)
|
.from(events)
|
||||||
.where(eq(events.visibility, 'public'))
|
.where(inArray(events.visibility, ['public', 'invite-only']))
|
||||||
.orderBy(desc(events.createdAt));
|
.orderBy(desc(events.createdAt));
|
||||||
|
|
||||||
// Transform the database events to match the expected Event interface
|
// Transform the database events to match the expected Event interface
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Event, EventType } from '$lib/types';
|
import type { Event, EventType } from '$lib/types';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { PageData } from '../$types';
|
|
||||||
import { formatTime, formatDate, isEventInTimeRange } from '$lib/dateHelpers';
|
import { formatTime, formatDate, isEventInTimeRange } from '$lib/dateHelpers';
|
||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
|
|
||||||
|
type DiscoverPageData = {
|
||||||
|
events: Event[];
|
||||||
|
};
|
||||||
|
|
||||||
let publicEvents: Event[] = [];
|
let publicEvents: Event[] = [];
|
||||||
let error = '';
|
let error = '';
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
@@ -16,7 +19,7 @@
|
|||||||
let showFilters = false;
|
let showFilters = false;
|
||||||
let fuse: Fuse<Event>;
|
let fuse: Fuse<Event>;
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: DiscoverPageData;
|
||||||
// Use the server-side data
|
// Use the server-side data
|
||||||
$: publicEvents = data?.events || [];
|
$: publicEvents = data?.events || [];
|
||||||
|
|
||||||
@@ -67,8 +70,15 @@
|
|||||||
|
|
||||||
// Sort events by date and time
|
// Sort events by date and time
|
||||||
events = events.sort((a, b) => {
|
events = events.sort((a, b) => {
|
||||||
const dateA = new Date(`${a.date}T${a.time}`);
|
// Parse dates as local timezone to avoid timezone issues
|
||||||
const dateB = new Date(`${b.date}T${b.time}`);
|
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') {
|
if (selectedSortOrder === 'asc') {
|
||||||
return dateA.getTime() - dateB.getTime();
|
return dateA.getTime() - dateB.getTime();
|
||||||
@@ -314,6 +324,16 @@
|
|||||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
const event = eventData[0];
|
const event = eventData[0];
|
||||||
const eventRsvps = rsvpData;
|
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
|
// Transform the data to match the expected interface
|
||||||
const transformedEvent = {
|
const transformedEvent = {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
@@ -85,6 +95,11 @@ export const actions: Actions = {
|
|||||||
return fail(404, { error: 'Event not found' });
|
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
|
// Get current RSVPs
|
||||||
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
const currentRSVPs = await database.select().from(rsvps).where(eq(rsvps.eventId, eventId));
|
||||||
|
|
||||||
|
|||||||
@@ -10,23 +10,28 @@
|
|||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
export let data: { event: Event; rsvps: RSVP[]; userId: string };
|
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 event: Event;
|
||||||
let rsvps: RSVP[] = [];
|
let rsvps: RSVP[] = [];
|
||||||
let newAttendeeName = '';
|
let newAttendeeName = '';
|
||||||
let isAddingRSVP = false;
|
let isAddingRSVP = false;
|
||||||
let error = '';
|
let error = '';
|
||||||
let success = '';
|
let success = ''; // TODO: change to boolean and refactor with 482-506
|
||||||
let addGuests = false;
|
let addGuests = false;
|
||||||
let numberOfGuests = 1;
|
let numberOfGuests = 1;
|
||||||
let showCalendarModal = false;
|
let showCalendarModal = false;
|
||||||
let calendarEvent: CalendarEvent;
|
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
|
// Use server-side data
|
||||||
$: event = data.event;
|
$: event = data.event;
|
||||||
$: rsvps = data.rsvps;
|
$: rsvps = data.rsvps;
|
||||||
$: currentUserId = data.userId;
|
$: currentUserId = data.userId;
|
||||||
|
$: isEventCreator = event.user_id === currentUserId;
|
||||||
|
|
||||||
// Create calendar event object when event data changes
|
// Create calendar event object when event data changes
|
||||||
$: if (event && browser) {
|
$: if (event && browser) {
|
||||||
@@ -45,24 +50,47 @@
|
|||||||
success = '';
|
success = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle form success from server
|
const handleFormSuccess = () => {
|
||||||
$: if (form?.success) {
|
if (form?.type === 'add') {
|
||||||
success = 'RSVP added successfully!';
|
success = 'RSVP added successfully!';
|
||||||
|
} else {
|
||||||
|
success = 'RSVP removed successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
error = '';
|
error = '';
|
||||||
newAttendeeName = '';
|
newAttendeeName = '';
|
||||||
addGuests = false;
|
addGuests = false;
|
||||||
numberOfGuests = 1;
|
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 eventId = $page.params.id || '';
|
||||||
|
|
||||||
const copyEventLink = () => {
|
const copyEventLink = () => {
|
||||||
if (browser) {
|
if (browser && isEventCreator) {
|
||||||
const url = `${$page.url.origin}/event/${eventId}`;
|
const url = `${$page.url.origin}/event/${eventId}`;
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
success = 'Event link copied to clipboard!';
|
toastType = 'copy';
|
||||||
|
success = t('event.eventLinkCopied');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
success = '';
|
success = '';
|
||||||
|
toastType = null;
|
||||||
}, 3000);
|
}, 3000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,6 +99,7 @@
|
|||||||
const clearMessages = () => {
|
const clearMessages = () => {
|
||||||
error = '';
|
error = '';
|
||||||
success = '';
|
success = '';
|
||||||
|
toastType = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calendar modal functions
|
// Calendar modal functions
|
||||||
@@ -208,7 +237,13 @@
|
|||||||
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
<div class=" rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
<h3 class=" mb-4 text-xl font-bold">{t('event.joinThisEvent')}</h3>
|
<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="py-6 text-center">
|
||||||
<div class="mb-3 text-4xl text-red-400">🚫</div>
|
<div class="mb-3 text-4xl text-red-400">🚫</div>
|
||||||
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
|
<p class="font-semibold text-red-400">{t('event.eventIsFull')}</p>
|
||||||
@@ -316,6 +351,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attendees List -->
|
<!-- Attendees List -->
|
||||||
|
{#if event.visibility !== 'invite-only'}
|
||||||
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
||||||
@@ -386,7 +422,12 @@
|
|||||||
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
|
class="text-dark-400 p-1 transition-colors duration-200 hover:text-red-400"
|
||||||
aria-label={t('event.removeRsvpAriaLabel')}
|
aria-label={t('event.removeRsvpAriaLabel')}
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
@@ -402,15 +443,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="max-w-2xl space-y-3">
|
<div class="max-w-2xl space-y-3">
|
||||||
|
{#if event.visibility !== 'invite-only'}
|
||||||
<button
|
<button
|
||||||
on:click={copyEventLink}
|
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"
|
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')}
|
{t('event.copyLinkButton')}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
on:click={openCalendarModal}
|
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"
|
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 -->
|
<!-- Success/Error Messages -->
|
||||||
{#if success}
|
{#if success}
|
||||||
{#if form?.type === 'add'}
|
{#if typeToShow === 'add'}
|
||||||
<div
|
<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"
|
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}
|
{success}
|
||||||
</div>
|
</div>
|
||||||
{:else if form?.type === 'remove'}
|
{:else if typeToShow === 'remove'}
|
||||||
<div
|
<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"
|
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')}
|
{t('event.removedRsvpSuccessfully')}
|
||||||
</div>
|
</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}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { database } from '$lib/database/db';
|
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 { eq, and } from 'drizzle-orm';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
@@ -23,8 +23,30 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
throw redirect(303, '/event');
|
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 {
|
return {
|
||||||
event: event[0]
|
event: event[0],
|
||||||
|
inviteToken,
|
||||||
|
userId
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,8 +108,9 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if date is in the past (but allow editing past events for corrections)
|
// Check if date is in the past using local timezone (but allow editing past events for corrections)
|
||||||
const eventDate = new Date(date);
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
const eventDate = new Date(year, month - 1, day);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@
|
|||||||
|
|
||||||
let errors: Record<string, string> = {};
|
let errors: Record<string, string> = {};
|
||||||
let isSubmitting = false;
|
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
|
// Get today's date in YYYY-MM-DD format for min attribute
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -67,6 +71,24 @@
|
|||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
goto(`/event/${data.event.id}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -315,7 +337,7 @@
|
|||||||
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
<legend class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||||
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
|
{t('common.visibility')} <span class="text-red-400">{t('common.required')}</span>
|
||||||
</legend>
|
</legend>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.visibility ===
|
||||||
@@ -336,15 +358,59 @@
|
|||||||
>
|
>
|
||||||
{t('create.privateOption')}
|
{t('create.privateOption')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400">
|
<p class="mt-2 text-xs text-slate-400">
|
||||||
{eventData.visibility === 'public'
|
{eventData.visibility === 'public'
|
||||||
? t('create.publicDescription')
|
? t('create.publicDescription')
|
||||||
: t('create.privateDescription')}
|
: eventData.visibility === 'private'
|
||||||
|
? t('create.privateDescription')
|
||||||
|
: t('create.inviteOnlyDescription')}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</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 -->
|
<!-- Action Buttons -->
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
@@ -374,3 +440,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
223
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal file
223
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
472
src/routes/event/[id]/invite/[token]/+page.svelte
Normal file
472
src/routes/event/[id]/invite/[token]/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user