mirror of
https://github.com/polaroi8d/cactoide.git
synced 2026-03-22 22:25:29 +00:00
Compare commits
85 Commits
0.1.1
...
fix/permis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
662476f820 | ||
|
|
7ebf95bb16 | ||
|
|
0491ec4c4b | ||
|
|
fcdef065d7 | ||
|
|
a1fa879f36 | ||
|
|
b723aac180 | ||
|
|
277ad3ff14 | ||
|
|
52d48e4839 | ||
|
|
5b7178bec1 | ||
|
|
7531af9d29 | ||
|
|
94152b6740 | ||
|
|
6155cc44da | ||
|
|
719cd23350 | ||
|
|
87d2275373 | ||
|
|
6e314af82b | ||
|
|
2cb74bccd0 | ||
|
|
0ecaf54227 | ||
|
|
4179bca981 | ||
|
|
406a669a98 | ||
|
|
7d75020cc1 | ||
|
|
73c92b800a | ||
|
|
1faa45e76b | ||
|
|
8a45ad60fb | ||
|
|
258b822a27 | ||
|
|
c3f420df74 | ||
|
|
9f74d58db1 | ||
|
|
efe465d994 | ||
|
|
1b79e6da58 | ||
|
|
5ea620762a | ||
|
|
e5be4b5589 | ||
|
|
8cde1d44eb | ||
|
|
7692f9d503 | ||
|
|
baf3fcd923 | ||
|
|
5468bc7cb2 | ||
|
|
0afe331cab | ||
|
|
fa9a79192c | ||
|
|
7275084ab9 | ||
|
|
02975a0abd | ||
|
|
aebe477f90 | ||
|
|
325237d414 | ||
|
|
24e9d8b626 | ||
|
|
05556eefdb | ||
|
|
2273ae50a4 | ||
|
|
e75c7e40dc | ||
|
|
2a96d3762c | ||
|
|
935042dd06 | ||
|
|
5809cb49ee | ||
|
|
93b0bac48a | ||
|
|
c9c78d0ea6 | ||
|
|
f6b51232a7 | ||
|
|
bb573c603a | ||
|
|
75fa7a9528 | ||
|
|
eb5543fb9b | ||
|
|
706e6cf712 | ||
|
|
c6decaa0d1 | ||
|
|
d193283b11 | ||
|
|
accfd540f0 | ||
|
|
9b1ef64618 | ||
|
|
c340088434 | ||
|
|
984c296725 | ||
|
|
9acfa08ea8 | ||
|
|
45cb95f6a8 | ||
|
|
8426bd5704 | ||
|
|
b9833db3bb | ||
|
|
b3572293ba | ||
|
|
c1752efe4b | ||
|
|
491d0020bd | ||
|
|
5c1182dc66 | ||
|
|
638b5ff1ca | ||
|
|
069ca11917 | ||
|
|
4675fa4623 | ||
|
|
af88d6462b | ||
|
|
ef6005e648 | ||
|
|
11875b4a1e | ||
|
|
22038f7779 | ||
|
|
de2cb07a15 | ||
|
|
ffc29b9c24 | ||
|
|
4860b9439c | ||
|
|
d10af13134 | ||
|
|
c98260efec | ||
|
|
a40b83c2b3 | ||
|
|
bfb76aa268 | ||
|
|
f8758d7b47 | ||
|
|
f51f89e35f | ||
|
|
26824eb3a8 |
23
.env.docker.example
Normal file
23
.env.docker.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Logger configuration
|
||||||
|
LOG_PRETTY=true
|
||||||
|
LOG_LEVEL="trace"
|
||||||
|
|
||||||
|
# If you don't want to use the default home page you can turn off
|
||||||
|
# in this case the /discovery page remain the home of your site
|
||||||
|
PUBLIC_LANDING_INFO=true
|
||||||
|
|
||||||
|
# Federation config
|
||||||
|
FEDERATION_INSTANCE=false
|
||||||
|
|
||||||
16
.env.example
16
.env.example
@@ -4,12 +4,20 @@ 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"
|
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
|
||||||
APP_VERSION=latest
|
APP_VERSION=latest
|
||||||
PORT=3000
|
PORT=5173
|
||||||
HOSTNAME=0.0.0.0
|
HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
# Logger configuration
|
||||||
|
LOG_PRETTY=true
|
||||||
|
LOG_LEVEL=trace
|
||||||
|
|
||||||
|
# If you don't want to use the default home page you can turn off
|
||||||
|
# in this case the /discovery page remain the home of your site
|
||||||
|
PUBLIC_LANDING_INFO=true
|
||||||
|
|
||||||
|
# Federation config
|
||||||
|
FEDERATION_INSTANCE=true
|
||||||
|
|||||||
9
.github/workflows/build-and-push.yml
vendored
9
.github/workflows/build-and-push.yml
vendored
@@ -1,5 +1,7 @@
|
|||||||
# .github/workflows/docker-build-and-push.yml
|
|
||||||
name: build & push the images
|
name: build & push the images
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -60,6 +62,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
build-args: |
|
||||||
|
FEDERATION_INSTANCE=${{ vars.FEDERATION_INSTANCE }}
|
||||||
|
PUBLIC_LANDING_INFO=${{ vars.PUBLIC_LANDING_INFO }}
|
||||||
|
LOG_PRETTY=${{ vars.LOG_PRETTY }}
|
||||||
|
LOG_LEVEL=${{ vars.LOG_LEVEL }}
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -1,4 +1,7 @@
|
|||||||
name: test & build
|
name: test & build
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -28,6 +31,11 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
env:
|
||||||
|
FEDERATION_INSTANCE: ${{ vars.FEDERATION_INSTANCE }}
|
||||||
|
PUBLIC_LANDING_INFO: ${{ vars.PUBLIC_LANDING_INFO }}
|
||||||
|
LOG_PRETTY: ${{ vars.LOG_PRETTY }}
|
||||||
|
LOG_LEVEL: ${{ vars.LOG_LEVEL }}
|
||||||
|
|
||||||
- name: Test build output
|
- name: Test build output
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
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-*
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@@ -3,25 +3,36 @@ WORKDIR /app
|
|||||||
COPY package*.json .
|
COPY package*.json .
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
ARG PUBLIC_LANDING_INFO
|
||||||
|
ENV PUBLIC_LANDING_INFO=$PUBLIC_LANDING_INFO
|
||||||
|
|
||||||
|
ARG FEDERATION_INSTANCE
|
||||||
|
ENV FEDERATION_INSTANCE=$FEDERATION_INSTANCE
|
||||||
|
|
||||||
|
ARG LOG_PRETTY
|
||||||
|
ENV LOG_PRETTY=$LOG_PRETTY
|
||||||
|
|
||||||
|
ARG LOG_LEVEL
|
||||||
|
ENV LOG_LEVEL=$LOG_LEVEL
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
RUN npm prune --production
|
RUN npm prune --production
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/build build/
|
COPY --from=builder /app/build build/
|
||||||
COPY --from=builder /app/node_modules node_modules/
|
COPY --from=builder /app/node_modules node_modules/
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
ENV PORT 3000
|
ENV PORT 3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/healthz || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/healthz || exit 1
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
CMD [ "node", "build" ]
|
CMD [ "node", "build" ]
|
||||||
|
|||||||
104
Makefile
104
Makefile
@@ -1,21 +1,55 @@
|
|||||||
.PHONY: help build up db-only logs db-clean prune
|
.PHONY: help build up down db-only db-seed logs db-clean prune i18n lint format migrate-up migrate-down
|
||||||
|
|
||||||
|
# Database connection variables
|
||||||
|
DB_HOST ?= localhost
|
||||||
|
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 " db-seed - Seed the database with sample data"
|
||||||
@echo "Individual services:"
|
@echo " logs - Show logs from all services"
|
||||||
@echo " make db-only - Start only the database"
|
@echo " db-clean - Clean up all Docker resources"
|
||||||
@echo ""
|
@echo " prune - Clean up everything (containers, images, volumes)"
|
||||||
@echo "Utility commands:"
|
@echo " i18n - List missing keys in translation file (use FILE=path/to/file.json)"
|
||||||
@echo " make logs - Show logs from all services"
|
@echo " lint - Lint the project"
|
||||||
@echo " make db-clean - Stop & remove database container"
|
@echo " format - Format the project"
|
||||||
@echo " make prune - Remove all containers, images, and volumes"
|
@echo " migrate-up - Apply invite-only events migration"
|
||||||
@echo " make help - Show this help message"
|
@echo " migrate-down - Rollback invite-only events migration"
|
||||||
|
|
||||||
|
# Apply invite-only events migration
|
||||||
|
migrate-up:
|
||||||
|
@echo "Applying invite-only events migration..."
|
||||||
|
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" ]; then \
|
||||||
|
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql" && \
|
||||||
|
echo "Migration applied successfully!"; \
|
||||||
|
else \
|
||||||
|
echo "Migration file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events.sql"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rollback invite-only events migration
|
||||||
|
migrate-down:
|
||||||
|
@echo "Rolling back invite-only events migration..."
|
||||||
|
@if [ -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" ]; then \
|
||||||
|
psql "$(DB_URL)" -f "$(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql" && \
|
||||||
|
echo "Migration rolled back successfully!"; \
|
||||||
|
else \
|
||||||
|
echo "Rollback file not found: $(MIGRATIONS_DIR)/20241220_001_add_invite_only_events_rollback.sql"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Build the Docker images
|
# Build the Docker images
|
||||||
build:
|
build:
|
||||||
@@ -27,19 +61,36 @@ 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..."
|
||||||
docker compose up -d postgres
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
# Seed the database with sample data
|
||||||
|
db-seed:
|
||||||
|
@echo "Seeding database with sample data..."
|
||||||
|
@if [ -f "database/seed.sql" ]; then \
|
||||||
|
psql "$(DB_URL)" -f database/seed.sql && \
|
||||||
|
echo "Database seeded successfully!"; \
|
||||||
|
else \
|
||||||
|
echo "Seed file not found: database/seed.sql"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Show logs from all services
|
# Show logs from all services
|
||||||
logs:
|
logs:
|
||||||
@echo "Showing logs from all services..."
|
@echo "Showing logs from all services..."
|
||||||
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:
|
||||||
@@ -47,3 +98,18 @@ prune:
|
|||||||
docker compose down -v --rmi all
|
docker compose down -v --rmi all
|
||||||
|
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Linting the project..."
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
format:
|
||||||
|
@echo "Formatting the project..."
|
||||||
|
npm run format
|
||||||
|
|
||||||
|
# List missing keys in a translation file
|
||||||
|
i18n:
|
||||||
|
@if [ -z "$(FILE)" ]; then \
|
||||||
|
echo "Error: FILE variable is required. Example: make i18n FILE=src/lib/i18n/it.json"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@./scripts/i18n-check.sh --missing-only $(FILE)
|
||||||
|
|||||||
131
README.md
131
README.md
@@ -5,26 +5,34 @@ Events that thrive anywhere.
|
|||||||
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.
|
Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cactoide.dalev.hu/" target="blank">
|
<a href="https://cactoide.org/" target="blank">
|
||||||
<picture>
|
<picture>
|
||||||
<img alt="actoide" src="https://github.com/user-attachments/assets/30b87181-1e3b-49d0-869e-bef6dcf7f777" width="840">
|
<img alt="actoide" src="https://github.com/user-attachments/assets/a7f7a732-1279-486e-808c-1d2348c68780" width="840">
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
#### What is it?
|
#### What is it?
|
||||||
|
|
||||||
A mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required.
|
A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances.
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|
||||||
- **🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency.
|
**🎯 Instant Event Creation** - Create events in seconds with our streamlined form. No accounts, no waiting, just pure efficiency.
|
||||||
- **🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
|
|
||||||
- **🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place.
|
**🔗 One-Click Sharing** - Each event gets a unique, memorable URL. Share instantly via any platform or messaging app.
|
||||||
- **📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
|
|
||||||
- **👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers.
|
**🌐 Federation** - Connect with other Cactoide instances to discover events across the network. Share your public events and creating a decentralized event discovery network.
|
||||||
- **🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
|
|
||||||
- **✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go.
|
**🔍 All-in-One Clarity** - No more scrolling through endless chats and reactions. See everyone's availability and responses neatly in one place.
|
||||||
|
|
||||||
|
**📅 iCal Integration** - One-tap add-to-calendar via ICS/webcal links. Works with Apple Calendar, Google Calendar, and Outlook, with automatic time zone handling.
|
||||||
|
|
||||||
|
**👤 No Hassle, No Sign-Ups** - Skip registrations and endless forms. Unlike other event platforms, you create and share instantly — no accounts, no barriers.
|
||||||
|
|
||||||
|
**🛡️ Smart Limits** - Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.
|
||||||
|
|
||||||
|
**✨ Effortless Simplicity** - Designed to be instantly clear and easy. No learning curve — just open, create, and go.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
@@ -37,7 +45,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,17 +54,114 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Build the image in local
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build \
|
||||||
|
--build-arg LOG_PRETTY=${LOG_PRETTY:-true} \
|
||||||
|
--build-arg LOG_LEVEL=${LOG_LEVEL:-trace} \
|
||||||
|
--build-arg PUBLIC_LANDING_INFO=${PUBLIC_LANDING_INFO:-true} \
|
||||||
|
--build-arg FEDERATION_INSTANCE=${FEDERATION_INSTANCE:-true} \
|
||||||
|
-t cactoide-example .
|
||||||
|
```
|
||||||
|
|
||||||
Your app will be available at `http://localhost:5173`. You can use the Makefile commands to run the application or the database, eg.: `make db-only`.
|
Your app will be available at `http://localhost:5173`. You can use the Makefile commands to run the application or the database, eg.: `make db-only`.
|
||||||
|
|
||||||
Use the `database/seed.sql` if you want to populate your database with dummy data.
|
Use the `database/seed.sql` if you want to populate your database with dummy data.
|
||||||
|
|
||||||
|
### Federation
|
||||||
|
|
||||||
|
Cactoide supports federation, allowing multiple instances to share and discover public events across the network. This enables users to discover events from other Cactoide instances, creating a decentralized event discovery network.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img alt="Federation Example" src="./docs/federation_example.png" width="840">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
#### How it works
|
||||||
|
|
||||||
|
Federation is managed through the `federation.config.js` file, which contains:
|
||||||
|
|
||||||
|
- **Instance name**: The display name for your instance when exposing events to the federation
|
||||||
|
- **Instance list**: An array of federated instance URLs. Add instance URLs here to discover events from other federated instances.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const config = {
|
||||||
|
name: 'Cactoide Genesis',
|
||||||
|
instances: [{ url: 'js-meetups.seattle.io' }, { url: 'ai-events.seattle.com' }]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Opt-in
|
||||||
|
|
||||||
|
To enable federation on your instance, you need to:
|
||||||
|
|
||||||
|
1. **Set the environment variable**: Add `FEDERATION_INSTANCE=true` to your `.env` file. This enables the federation API endpoints on your instance.
|
||||||
|
|
||||||
|
2. **Configure your instance name**: Update the `name` field in your `federation.config.js` file to set your instance's display name.
|
||||||
|
|
||||||
|
Your instance will automatically expose:
|
||||||
|
|
||||||
|
- `/api/federation/events` - Returns all public events from your instance
|
||||||
|
- `/api/federation/info` - Returns your instance name and public events count
|
||||||
|
|
||||||
|
#### Adding your instance
|
||||||
|
|
||||||
|
To add your instance to the global federation list (so other instances can discover your events):
|
||||||
|
|
||||||
|
1. Fork the [Cactoide repository](https://github.com/polaroi8d/cactoide)
|
||||||
|
2. Add your instance URL to the `instances` array in `federation.config.js`:
|
||||||
|
3. Open a pull request to the main repository
|
||||||
|
|
||||||
|
Once merged, your instance will appear in the federation network, and other instances will be able to discover and display your public events.
|
||||||
|
|
||||||
|
You can view all registered federated instances in the main repository: `federation.config.js` file.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
#### 1. Landing page option
|
||||||
|
|
||||||
|
Supports a conditional landing page display based on the `PUBLIC_LANDING_INFO` environment variable. If you don't want to show your users the cactoide landing page, just use the `PUBLIC_LANDING_INFO=false` variable. This will automatically remove the landing home page and redirect users to the `/discover` page.
|
||||||
|
|
||||||
|
This is useful for:
|
||||||
|
|
||||||
|
- Creating a minimal discovery-focused experience
|
||||||
|
- Customizing the user journey based on deployment environment
|
||||||
|
|
||||||
|
#### 2. i18n
|
||||||
|
|
||||||
|
There is no proper i18n implemented, we have an `/i18n` folder with specific languages. To use an existing translation, just rename the language code JSON file to `messages.json` and you are ready to go. If you would like to add a new translation (which is really appreciated), just create a new `<language_code>.json` file and add the translations from the `messages.json`.
|
||||||
|
|
||||||
|
The project includes a translation validation script to ensure all translation files are complete and up-to-date with the source `messages.json` file.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate all translation files
|
||||||
|
make i18n
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Validate a specific translation file
|
||||||
|
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:
|
||||||
|
|
||||||
|
- 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 MIT 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.
|
||||||
|
|
||||||
**Made with ❤️ by @polaroi8d**
|
**Made with ❤️ by @polaroi8d**
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ CREATE TABLE IF NOT EXISTS events (
|
|||||||
date DATE NOT NULL,
|
date DATE NOT NULL,
|
||||||
time TIME NOT NULL,
|
time TIME NOT NULL,
|
||||||
location VARCHAR(200) NOT NULL,
|
location VARCHAR(200) NOT NULL,
|
||||||
location_type VARCHAR(20) NOT NULL DEFAULT 'text' CHECK (location_type IN ('text','maps')),
|
location_type VARCHAR(20) NOT NULL DEFAULT 'none' CHECK (location_type IN ('none','text','maps')),
|
||||||
location_url VARCHAR(500),
|
location_url VARCHAR(500),
|
||||||
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
|
type VARCHAR(20) NOT NULL CHECK (type IN ('limited','unlimited')),
|
||||||
attendee_limit INTEGER CHECK (attendee_limit > 0),
|
attendee_limit INTEGER CHECK (attendee_limit > 0),
|
||||||
user_id VARCHAR(100) NOT NULL,
|
user_id VARCHAR(100) NOT NULL,
|
||||||
visibility VARCHAR(20) NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','private')),
|
visibility VARCHAR(20) NOT NULL DEFAULT 'public' CHECK (visibility IN ('public','private', 'invite-only')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -34,6 +34,15 @@ CREATE TABLE IF NOT EXISTS rsvps (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Invite tokens
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id VARCHAR(8) NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(32) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- =======================================
|
-- =======================================
|
||||||
-- Indexes
|
-- Indexes
|
||||||
-- =======================================
|
-- =======================================
|
||||||
@@ -42,22 +51,9 @@ CREATE INDEX IF NOT EXISTS idx_events_date ON events(date);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
|
CREATE INDEX IF NOT EXISTS idx_events_location_type ON events(location_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
|
CREATE INDEX IF NOT EXISTS idx_rsvps_event_id ON rsvps(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
CREATE INDEX IF NOT EXISTS idx_rsvps_user_id ON rsvps(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_event_id ON invite_tokens(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_token ON invite_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_tokens_expires_at ON invite_tokens(expires_at);
|
||||||
|
|
||||||
-- =======================================
|
|
||||||
-- 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;
|
||||||
@@ -34,9 +34,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '${PORT:-5111}:3000'
|
- '${PORT:-5111}:3000'
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL:-postgres://cactoide:cactoide_password@postgres:5432/cactoide_database}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: ${HOSTNAME:-0.0.0.0}
|
HOSTNAME: ${HOSTNAME:-0.0.0.0}
|
||||||
|
LOG_PRETTY: ${LOG_PRETTY:-true}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-trace}
|
||||||
|
PUBLIC_LANDING_INFO: ${PUBLIC_LANDING_INFO:-true}
|
||||||
|
FEDERATION_INSTANCE: ${FEDERATION_INSTANCE:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
BIN
docs/federation_example.png
Normal file
BIN
docs/federation_example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
426
package-lock.json
generated
426
package-lock.json
generated
@@ -1,16 +1,18 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
|
"pino": "^10.1.0",
|
||||||
|
"pino-pretty": "^13.1.2",
|
||||||
"postgres": "^3.4.7"
|
"postgres": "^3.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -22,7 +24,8 @@
|
|||||||
"@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",
|
||||||
|
"@types/node": "^24.9.1",
|
||||||
"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 +38,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": {
|
||||||
@@ -1231,6 +1234,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -1643,6 +1652,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 +1685,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 +1960,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",
|
||||||
@@ -2034,14 +2105,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.3.0",
|
"version": "24.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
||||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.10.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
@@ -2096,6 +2167,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 +2385,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"
|
||||||
},
|
},
|
||||||
@@ -2379,6 +2452,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -2508,6 +2590,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/colorette": {
|
||||||
|
"version": "2.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||||
|
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/commondir": {
|
"node_modules/commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
@@ -2558,6 +2646,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dateformat": {
|
||||||
|
"version": "4.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||||
|
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@@ -2602,15 +2699,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": {
|
||||||
@@ -2748,6 +2845,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.3",
|
"version": "5.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
|
||||||
@@ -2768,6 +2874,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 +2942,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",
|
||||||
@@ -3055,6 +3163,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-copy": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3106,6 +3220,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-safe-stringify": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
@@ -3304,6 +3424,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/help-me": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3421,6 +3547,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/joycon": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
@@ -3846,6 +3981,15 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -3934,6 +4078,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/once": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wrappy": "1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -4041,6 +4203,79 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty": {
|
||||||
|
"version": "13.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.2.tgz",
|
||||||
|
"integrity": "sha512-3cN0tCakkT4f3zo9RXDIhy6GTvtYD6bK4CRBLN9j3E/ePqN1tugAXD5rGVfoChW6s0hiek+eyYlLNqc/BG7vBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"colorette": "^2.0.7",
|
||||||
|
"dateformat": "^4.6.3",
|
||||||
|
"fast-copy": "^3.0.2",
|
||||||
|
"fast-safe-stringify": "^2.1.1",
|
||||||
|
"help-me": "^5.0.0",
|
||||||
|
"joycon": "^3.1.1",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^2.0.0",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"secure-json-parse": "^4.0.0",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"strip-json-comments": "^5.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino-pretty": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-pretty/node_modules/strip-json-comments": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -4060,6 +4295,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 +4418,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 +4443,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 +4460,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"
|
||||||
@@ -4314,6 +4553,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pump": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.1.0",
|
||||||
|
"once": "^1.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -4345,6 +4610,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -4359,6 +4630,15 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@@ -4415,6 +4695,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"
|
||||||
},
|
},
|
||||||
@@ -4485,6 +4766,31 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/secure-json-parse": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
@@ -4541,6 +4847,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -4571,6 +4886,15 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
@@ -4614,6 +4938,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 +5031,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",
|
||||||
@@ -4736,14 +5062,23 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/thread-stream": {
|
||||||
"version": "0.2.14",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||||
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
|
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.4.4",
|
"real-require": "^0.2.0"
|
||||||
"picomatch": "^4.0.2"
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyglobby": {
|
||||||
|
"version": "0.2.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fdir": "^6.5.0",
|
||||||
|
"picomatch": "^4.0.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -4806,6 +5141,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"
|
||||||
@@ -4839,12 +5175,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.10.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT",
|
"devOptional": true,
|
||||||
"optional": true,
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
@@ -4864,17 +5199,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"
|
||||||
@@ -4982,6 +5318,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrappy": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||||
@@ -4992,20 +5334,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",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cactoide",
|
"name": "cactoide",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.3",
|
"version": "0.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -22,6 +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",
|
||||||
|
"@types/node": "^24.9.1",
|
||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
@@ -35,12 +36,14 @@
|
|||||||
"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",
|
||||||
"drizzle-orm": "^0.44.5",
|
"drizzle-orm": "^0.44.5",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
|
"pino": "^10.1.0",
|
||||||
|
"pino-pretty": "^13.1.2",
|
||||||
"postgres": "^3.4.7"
|
"postgres": "^3.4.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
89
scripts/i18n-check.sh
Executable file
89
scripts/i18n-check.sh
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Find keys present in messages.json but missing in a translation file.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SOURCE_DEFAULT="src/lib/i18n/messages.json"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $0 [--missing-only] <translation.json> [source_messages.json]"
|
||||||
|
echo "Compares <translation.json> against messages.json and prints missing keys."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if jq is installed
|
||||||
|
command -v jq >/dev/null 2>&1 || {
|
||||||
|
echo "Error: jq is required but not installed." >&2
|
||||||
|
echo "Please install jq:" >&2
|
||||||
|
echo " macOS: brew install jq" >&2
|
||||||
|
echo " Ubuntu/Debian: sudo apt-get install jq" >&2
|
||||||
|
echo " CentOS/RHEL: sudo yum install jq" >&2
|
||||||
|
exit 127
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments (handle --missing-only flag for Makefile compatibility)
|
||||||
|
TRANSLATION=""
|
||||||
|
SOURCE="$SOURCE_DEFAULT"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--missing-only|-m)
|
||||||
|
# Flag is accepted but ignored (this script only does missing keys)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
-*)
|
||||||
|
echo "Error: Unknown option: $1" >&2
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ -z "$TRANSLATION" ]]; then
|
||||||
|
TRANSLATION="$1"
|
||||||
|
elif [[ "$SOURCE" == "$SOURCE_DEFAULT" ]]; then
|
||||||
|
SOURCE="$1"
|
||||||
|
else
|
||||||
|
echo "Error: Too many arguments" >&2
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate arguments
|
||||||
|
[[ -z "$TRANSLATION" ]] && {
|
||||||
|
echo "Error: Translation file is required" >&2
|
||||||
|
usage
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate files exist
|
||||||
|
[[ -f "$SOURCE" ]] || {
|
||||||
|
echo "Error: Source file not found: $SOURCE" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$TRANSLATION" ]] || {
|
||||||
|
echo "Error: Translation file not found: $TRANSLATION" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract all keys from a JSON file (dot-joined paths to scalar values)
|
||||||
|
keys() {
|
||||||
|
jq -r 'paths(scalars) | join(".")' "$1" | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find missing keys: in SOURCE but not in TRANSLATION
|
||||||
|
missing=$(comm -23 <(keys "$SOURCE") <(keys "$TRANSLATION"))
|
||||||
|
|
||||||
|
if [[ -z "$missing" ]]; then
|
||||||
|
echo "No missing keys found."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Missing keys in $(basename "$TRANSLATION"):"
|
||||||
|
echo "$missing" | sed 's/^/ - /'
|
||||||
|
echo
|
||||||
|
echo "Total missing keys: $(echo "$missing" | wc -l | tr -d ' ')"
|
||||||
|
exit 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';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
|
// Global flag to track if database health check has been performed
|
||||||
|
let dbHealthCheckPerformed = false;
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
// Perform database health check only once during application startup
|
||||||
|
if (!dbHealthCheckPerformed) {
|
||||||
|
try {
|
||||||
|
await ensureDatabaseConnection({
|
||||||
|
maxRetries: 3,
|
||||||
|
baseDelay: 1000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
dbHealthCheckPerformed = true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Database health check failed');
|
||||||
|
// The ensureDatabaseConnection function will exit the process
|
||||||
|
// if the database is unavailable, so this catch is just for safety
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cactoideUserId = event.cookies.get('cactoideUserId');
|
||||||
|
const userId = generateUserId();
|
||||||
|
|
||||||
|
const DAYS = 400; // practical upper bound in many browsers for cookies
|
||||||
|
const MAX_AGE = 60 * 60 * 24 * DAYS;
|
||||||
|
const PATH = '/';
|
||||||
|
|
||||||
|
if (!cactoideUserId) {
|
||||||
|
logger.debug({ userId }, 'No cactoideUserId cookie found, generating new one');
|
||||||
|
event.cookies.set('cactoideUserId', userId, { path: PATH, maxAge: MAX_AGE });
|
||||||
|
} else {
|
||||||
|
logger.debug({ cactoideUserId }, 'cactoideUserId cookie found, using existing one');
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
src/lib/components/FeatureCard.svelte
Normal file
19
src/lib/components/FeatureCard.svelte
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
emoji: string;
|
||||||
|
titleKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { emoji, titleKey, descriptionKey }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-sm border p-4 text-center">
|
||||||
|
<div class="mx-auto mb-2 flex h-20 w-20 items-center justify-center rounded-full">
|
||||||
|
<span class="text-4xl">{emoji}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-4 text-xl font-bold text-white">{t(titleKey)}</h3>
|
||||||
|
<p class="">{t(descriptionKey)}</p>
|
||||||
|
</div>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
import { PUBLIC_LANDING_INFO } from '$env/static/public';
|
||||||
|
|
||||||
// Check if current page is active
|
// Check if current page is active
|
||||||
const isActive = (path: string): boolean => {
|
const isActive = (path: string): boolean => {
|
||||||
@@ -24,12 +25,14 @@
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="md:flex md:items-center md:space-x-8">
|
<div class="md:flex md:items-center md:space-x-8">
|
||||||
<button
|
{#if PUBLIC_LANDING_INFO !== 'false'}
|
||||||
on:click={() => goto('/')}
|
<button
|
||||||
class={isActive('/') ? 'text-violet-400' : 'cursor-pointer'}
|
on:click={() => goto('/')}
|
||||||
>
|
class={isActive('/') ? 'text-violet-400' : 'cursor-pointer'}
|
||||||
{t('navigation.home')}
|
>
|
||||||
</button>
|
{t('navigation.home')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={() => goto('/discover')}
|
on:click={() => goto('/discover')}
|
||||||
@@ -45,6 +48,13 @@
|
|||||||
{t('navigation.create')}
|
{t('navigation.create')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
on:click={() => goto('/instance')}
|
||||||
|
class={isActive('/instance') ? 'text-violet-400' : 'cursor-pointer'}
|
||||||
|
>
|
||||||
|
{t('navigation.instance')}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
on:click={() => goto('/event')}
|
on:click={() => goto('/event')}
|
||||||
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}
|
class={isActive('/event') ? 'text-violet-400' : 'cursor-pointer'}
|
||||||
|
|||||||
13
src/lib/config/federation.config.js
Normal file
13
src/lib/config/federation.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const config = {
|
||||||
|
name: 'Cactoide Genesis',
|
||||||
|
instances: [
|
||||||
|
// {
|
||||||
|
// url: 'cactoide.org'
|
||||||
|
// }
|
||||||
|
// {
|
||||||
|
// url: 'YOUR_INSTANCE_URL'
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
108
src/lib/database/healthCheck.ts
Normal file
108
src/lib/database/healthCheck.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import postgres from 'postgres';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
|
interface HealthCheckOptions {
|
||||||
|
maxRetries?: number;
|
||||||
|
baseDelay?: number;
|
||||||
|
maxDelay?: number;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthCheckResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
attempts: number;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs a database health check with retry logic and exponential backoff
|
||||||
|
*/
|
||||||
|
export async function checkDatabaseHealth(
|
||||||
|
options: HealthCheckOptions = {}
|
||||||
|
): Promise<HealthCheckResult> {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
baseDelay = 1000, // 1 second
|
||||||
|
maxDelay = 10000, // 10 seconds
|
||||||
|
timeout = 5000 // 5 seconds
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (!env.DATABASE_URL) {
|
||||||
|
logger.error('DATABASE_URL environment variable is not set');
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'DATABASE_URL environment variable is not set',
|
||||||
|
attempts: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
|
||||||
|
logger.info({ maxRetries }, 'Starting database health check');
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
logger.debug({ attempt, maxRetries }, 'Testing database connection');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new connection for the health check
|
||||||
|
const client = postgres(env.DATABASE_URL, {
|
||||||
|
max: 1,
|
||||||
|
idle_timeout: 20,
|
||||||
|
connect_timeout: timeout / 1000, // Convert to seconds
|
||||||
|
onnotice: () => {} // Suppress notices
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the connection with a simple query
|
||||||
|
await client`SELECT 1 as health_check`;
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
logger.info({ attempt }, 'Database connection successful');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
attempts: attempt
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error as Error;
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.warn({ attempt, error: errorMessage }, 'Database connection failed');
|
||||||
|
|
||||||
|
// Don't wait after the last attempt
|
||||||
|
if (attempt < maxRetries) {
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
||||||
|
logger.debug({ delay }, 'Waiting before retry');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalError = lastError?.message || 'Unknown database connection error';
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
{ attempts: maxRetries, error: finalError },
|
||||||
|
'All database connection attempts failed'
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: finalError,
|
||||||
|
attempts: maxRetries
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs database health check and exits the process if it fails
|
||||||
|
*/
|
||||||
|
export async function ensureDatabaseConnection(options?: HealthCheckOptions): Promise<void> {
|
||||||
|
const result = await checkDatabaseHealth(options);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
logger.fatal(
|
||||||
|
{ error: result.error, attempts: result.attempts },
|
||||||
|
'Database connection failed after all retry attempts. Exiting application'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,8 +16,8 @@ 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', ['text', 'maps']);
|
export const locationTypeEnum = pgEnum('location_type', ['none', 'text', 'maps']);
|
||||||
|
|
||||||
// --- Events table
|
// --- Events table
|
||||||
export const events = pgTable(
|
export const events = pgTable(
|
||||||
@@ -28,7 +28,7 @@ export const events = pgTable(
|
|||||||
date: date('date', { mode: 'string' }).notNull(), // ISO 'YYYY-MM-DD'
|
date: date('date', { mode: 'string' }).notNull(), // ISO 'YYYY-MM-DD'
|
||||||
time: time('time', { withTimezone: false }).notNull(), // 'HH:MM:SS'
|
time: time('time', { withTimezone: false }).notNull(), // 'HH:MM:SS'
|
||||||
location: varchar('location', { length: 200 }).notNull(),
|
location: varchar('location', { length: 200 }).notNull(),
|
||||||
locationType: locationTypeEnum('location_type').notNull().default('text'),
|
locationType: locationTypeEnum('location_type').notNull().default('none'),
|
||||||
locationUrl: varchar('location_url', { length: 500 }),
|
locationUrl: varchar('location_url', { length: 500 }),
|
||||||
type: eventTypeEnum('type').notNull(),
|
type: eventTypeEnum('type').notNull(),
|
||||||
attendeeLimit: integer('attendee_limit'), // nullable in SQL
|
attendeeLimit: integer('attendee_limit'), // nullable in SQL
|
||||||
@@ -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
|
||||||
|
|||||||
81
src/lib/fetchFederatedEvents.ts
Normal file
81
src/lib/fetchFederatedEvents.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { logger } from '$lib/logger';
|
||||||
|
import type { Event } from '$lib/types';
|
||||||
|
|
||||||
|
import config from '$lib/config/federation.config.js';
|
||||||
|
|
||||||
|
interface FederationEventsResponse {
|
||||||
|
events: Array<Event & { federation?: boolean }>;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from a single federated instance
|
||||||
|
*/
|
||||||
|
async function fetchEventsFromInstance(instanceUrl: string): Promise<Event[]> {
|
||||||
|
try {
|
||||||
|
const apiUrl = `http://${instanceUrl}/api/federation/events`;
|
||||||
|
|
||||||
|
logger.debug({ apiUrl }, 'Fetching events from federated instance');
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch events from instance');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as FederationEventsResponse;
|
||||||
|
|
||||||
|
if (!data.events || !Array.isArray(data.events)) {
|
||||||
|
logger.warn({ apiUrl }, 'Invalid events response structure');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark events as federated and add source URL
|
||||||
|
const federatedEvents: Event[] = data.events.map((event) => ({
|
||||||
|
...event,
|
||||||
|
federation: true,
|
||||||
|
federation_url: `http://${instanceUrl}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ apiUrl, eventCount: federatedEvents.length },
|
||||||
|
'Successfully fetched federated events'
|
||||||
|
);
|
||||||
|
return federatedEvents;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
'Error fetching events from instance'
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches events from all configured federated instances
|
||||||
|
*/
|
||||||
|
export async function fetchAllFederatedEvents(): Promise<Event[]> {
|
||||||
|
if (!config || !config.instances || config.instances.length === 0) {
|
||||||
|
logger.debug('No federation config or instances found');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from all instances in parallel
|
||||||
|
const fetchPromises = config.instances.map((instance) => fetchEventsFromInstance(instance.url));
|
||||||
|
|
||||||
|
const results = await Promise.all(fetchPromises);
|
||||||
|
|
||||||
|
// Flatten all events into a single array
|
||||||
|
const allFederatedEvents = results.flat();
|
||||||
|
|
||||||
|
logger.info({ totalEvents: allFederatedEvents.length }, 'Completed fetching federated events');
|
||||||
|
|
||||||
|
return allFederatedEvents;
|
||||||
|
}
|
||||||
266
src/lib/i18n/it.json
Normal file
266
src/lib/i18n/it.json
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"required": "*",
|
||||||
|
"cancel": "Annulla",
|
||||||
|
"create": "Crea",
|
||||||
|
"edit": "Modifica",
|
||||||
|
"delete": "Elimina",
|
||||||
|
"view": "Visualizza",
|
||||||
|
"home": "Home",
|
||||||
|
"loading": "Caricamento...",
|
||||||
|
"error": "Errore",
|
||||||
|
"success": "Successo",
|
||||||
|
"name": "Nome",
|
||||||
|
"date": "Data",
|
||||||
|
"time": "Ora",
|
||||||
|
"location": "Luogo",
|
||||||
|
"locationType": "Tipo di Luogo",
|
||||||
|
"locationNone": "Nessuno",
|
||||||
|
"locationText": "Testo",
|
||||||
|
"locationMaps": "Google Maps",
|
||||||
|
"locationNoneDescription": "Nessun luogo specificato",
|
||||||
|
"locationTextDescription": "Inserisci il luogo come testo semplice.",
|
||||||
|
"locationMapsDescription": "Inserisci il link di Google Maps.",
|
||||||
|
"googleMapsUrl": "URL di Google Maps",
|
||||||
|
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
|
||||||
|
"type": "Tipo",
|
||||||
|
"visibility": "Visibilità",
|
||||||
|
"public": "Pubblico",
|
||||||
|
"private": "Privato",
|
||||||
|
"limited": "Limitato",
|
||||||
|
"unlimited": "Illimitato",
|
||||||
|
"capacity": "Capacità",
|
||||||
|
"attendees": "Partecipanti",
|
||||||
|
"attendeeLimit": "Limite di Partecipanti",
|
||||||
|
"enterLimit": "Inserisci il limite",
|
||||||
|
"enterEventName": "Inserisci il nome dell'evento",
|
||||||
|
"enterLocation": "Inserisci il luogo",
|
||||||
|
"enterYourName": "Inserisci il tuo nome",
|
||||||
|
"enterNumberOfGuests": "Inserisci il numero di ospiti",
|
||||||
|
"yourName": "Il tuo nome",
|
||||||
|
"numberOfGuests": "Numero di Ospiti",
|
||||||
|
"addGuests": "Aggiungi ospiti",
|
||||||
|
"joinEvent": "Partecipa all'Evento",
|
||||||
|
"addToCalendar": "Aggiungi al Calendario",
|
||||||
|
"close": "Chiudi",
|
||||||
|
"closeModal": "Chiudi finestra",
|
||||||
|
"removeRSVP": "Rimuovi RSVP",
|
||||||
|
"updating": "Aggiornamento...",
|
||||||
|
"creating": "Creazione...",
|
||||||
|
"adding": "Aggiunta...",
|
||||||
|
"updateEvent": "Aggiorna Evento",
|
||||||
|
"createEvent": "Crea Evento",
|
||||||
|
"createNewEvent": "Crea Nuovo Evento",
|
||||||
|
"createYourFirstEvent": "Crea il Tuo Primo Evento",
|
||||||
|
"editEvent": "Modifica Evento",
|
||||||
|
"deleteEvent": "Elimina Evento",
|
||||||
|
"myEvents": "I Miei Eventi",
|
||||||
|
"discover": "Scopri",
|
||||||
|
"noEventsYet": "Ancora Nessun Evento",
|
||||||
|
"noPublicEventsYet": "Ancora Nessun Evento Pubblico",
|
||||||
|
"noAttendeesYet": "Ancora nessun partecipante",
|
||||||
|
"beFirstToJoin": "Sii il primo a partecipare!",
|
||||||
|
"eventNotFound": "Evento Non Trovato",
|
||||||
|
"eventIsFull": "L'Evento è Pieno!",
|
||||||
|
"maximumCapacityReached": "Raggiunta la capacità massima",
|
||||||
|
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
||||||
|
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
||||||
|
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
|
||||||
|
"somethingWentWrong": "Qualcosa è andato storto. Riprova.",
|
||||||
|
"failedToAddRsvp": "Impossibile aggiungere RSVP",
|
||||||
|
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
|
||||||
|
"failedToDeleteEvent": "Impossibile eliminare l'evento",
|
||||||
|
"youMayNotHavePermission": "Potresti non avere il permesso di eliminare questo evento.",
|
||||||
|
"anErrorOccurredWhileDeleting": "Si è verificato un errore durante l'eliminazione dell'evento:",
|
||||||
|
"databaseUnreachable": "Database non raggiungibile.",
|
||||||
|
"eventIdNotFound": "EventId non trovato",
|
||||||
|
"eventNotExists": "Evento non trovato",
|
||||||
|
"failedToLoadEvent": "Impossibile caricare l'evento",
|
||||||
|
"nameAndUserIdRequired": "Nome e ID utente sono obbligatori",
|
||||||
|
"eventCapacityExceeded": "Capacità dell'evento superata. Stai cercando di aggiungere {guests} partecipanti (te compreso/a), ma rimangono solo {remaining} posti.",
|
||||||
|
"nameAlreadyExists": "Il nome esiste già per questo evento",
|
||||||
|
"missingOrEmptyFields": "Campi mancanti o vuoti: {fields}",
|
||||||
|
"dateCannotBeInPast": "La data non può essere nel passato.",
|
||||||
|
"limitMustBeAtLeast2": "Il limite deve essere almeno 2 per eventi limitati.",
|
||||||
|
"unauthorized": "Non autorizzato",
|
||||||
|
"youCanOnlyEditYourOwnEvents": "Puoi modificare solo i tuoi eventi",
|
||||||
|
"youDoNotHavePermissionToDelete": "Non hai il permesso di eliminare questo evento",
|
||||||
|
"eventIdAndUserIdRequired": "ID evento e ID utente sono obbligatori",
|
||||||
|
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
|
||||||
|
"yourNamePlaceholder": "Il tuo nome",
|
||||||
|
"atTime": "alle"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"home": "Home",
|
||||||
|
"discover": "Scopri",
|
||||||
|
"create": "Crea",
|
||||||
|
"myEvents": "I Miei Eventi",
|
||||||
|
"instance": "Istanza"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Cactoide - Il sito per gli RSVP",
|
||||||
|
"description": "Crea e gestisci gli RSVP degli eventi. Nessuna registrazione richiesta, condivisione immediata.",
|
||||||
|
"mainTitle": "Cactoide(ea)",
|
||||||
|
"subtitle": "La Piattaforma Definitiva per gli RSVP",
|
||||||
|
"tagline": "Crea, condividi e gestisci eventi senza intoppi.",
|
||||||
|
"whyCactoideTitle": "Perché Cactoide(ae)? 🌵",
|
||||||
|
"whyCactoideDescription": "Come il cactus, i grandi eventi fioriscono in ogni condizione se gestiti con cura. Cactoide(ae) ti aiuta a semplificare gli RSVP, coordinare in modo semplice e mantenere ogni dettaglio efficiente: così i tuoi incontri sono resilienti, vivaci e indimenticabili.",
|
||||||
|
"createEventNow": "Crea Evento Ora",
|
||||||
|
"discoverPublicEventsTitle": "Scopri Eventi Pubblici",
|
||||||
|
"discoverPublicEventsDescription": "Guarda cosa stanno pianificando gli altri e lasciati ispirare",
|
||||||
|
"browseAllPublicEvents": "Sfoglia Tutti gli Eventi Pubblici",
|
||||||
|
"whyCactoideFeatureTitle": "Perché Cactoide?",
|
||||||
|
"instantEventCreationTitle": "Creazione Istantanea di Eventi",
|
||||||
|
"instantEventCreationDescription": "Crea eventi in pochi secondi con il nostro modulo semplificato. Nessun account, nessuna attesa, solo pura efficienza.",
|
||||||
|
"oneClickSharingTitle": "Condivisione con un Clic",
|
||||||
|
"oneClickSharingDescription": "Ogni evento ottiene un URL unico e memorabile. Condividi istantaneamente tramite qualsiasi piattaforma o app di messaggistica.",
|
||||||
|
"allInOneClarityTitle": "Chiarezza Tutto-in-Uno",
|
||||||
|
"allInOneClarityDescription": "Niente più scorrimento infinito tra chat e reazioni. Visualizza la disponibilità e le risposte di tutti in un unico posto.",
|
||||||
|
"noHassleNoSignUpsTitle": "Nessun Problema, Nessuna Registrazione",
|
||||||
|
"noHassleNoSignUpsDescription": "Salta le registrazioni e i moduli infiniti. A differenza di altre piattaforme di eventi, crei e condividi istantaneamente: nessun account, nessuna barriera.",
|
||||||
|
"smartLimitsTitle": "Limiti Intelligenti",
|
||||||
|
"smartLimitsDescription": "Scegli tra RSVP illimitati o imposta una capacità limitata. Perfetto per eventi di qualsiasi dimensione.",
|
||||||
|
"effortlessSimplicityTitle": "Semplicità Senza Sforzo",
|
||||||
|
"effortlessSimplicityDescription": "Progettato per essere immediatamente chiaro e facile. Nessuna curva di apprendimento: apri, crea e vai.",
|
||||||
|
"howItWorksTitle": "Come Funziona",
|
||||||
|
"step1Title": "Crea Evento",
|
||||||
|
"step1Description": "Compila un semplice modulo con i dettagli dell'evento. Scegli tra capacità limitata o illimitata.",
|
||||||
|
"step2Title": "Ottieni URL Unico",
|
||||||
|
"step2Description": "Ricevi un URL casuale e memorabile per il tuo evento. Perfetto per la condivisione ovunque.",
|
||||||
|
"step3Title": "Raccogli gli RSVP",
|
||||||
|
"step3Description": "Le persone visitano il tuo link e partecipano solo con il loro nome. Nessun account necessario.",
|
||||||
|
"ctaTitle": "Pronto a Creare il Tuo Primo Evento?",
|
||||||
|
"ctaDescription": "Unisciti a migliaia di organizzatori di eventi che si fidano di Cactoide",
|
||||||
|
"ctaButton": "Crea"
|
||||||
|
},
|
||||||
|
"create": {
|
||||||
|
"title": "Crea Evento - Cactoide",
|
||||||
|
"formTitle": "Crea Nuovo Evento",
|
||||||
|
"eventNameLabel": "Nome",
|
||||||
|
"eventNamePlaceholder": "Inserisci il nome dell'evento",
|
||||||
|
"dateLabel": "Data",
|
||||||
|
"timeLabel": "Ora",
|
||||||
|
"locationLabel": "Luogo",
|
||||||
|
"locationPlaceholder": "Inserisci il luogo",
|
||||||
|
"locationTypeLabel": "Tipo di Luogo",
|
||||||
|
"locationNoneOption": "Nessuno",
|
||||||
|
"locationTextOption": "Testo Semplice",
|
||||||
|
"locationMapsOption": "Google Maps",
|
||||||
|
"locationNoneDescription": "Nessun luogo specificato.",
|
||||||
|
"locationTextDescription": "Inserisci il luogo come testo semplice.",
|
||||||
|
"locationMapsDescription": "Inserisci il link di Google Maps.",
|
||||||
|
"googleMapsUrlLabel": "URL di Google Maps",
|
||||||
|
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
|
||||||
|
"typeLabel": "Tipo",
|
||||||
|
"unlimitedOption": "Illimitato",
|
||||||
|
"limitedOption": "Limitato",
|
||||||
|
"attendeeLimitLabel": "Limite di Partecipanti",
|
||||||
|
"attendeeLimitPlaceholder": "Inserisci il limite",
|
||||||
|
"visibilityLabel": "Visibilità",
|
||||||
|
"publicOption": "🌍 Pubblico",
|
||||||
|
"privateOption": "🔒 Privato",
|
||||||
|
"publicDescription": "Gli eventi pubblici sono visibili a tutti e possono essere scoperti da altri.",
|
||||||
|
"privateDescription": "Gli eventi privati sono visibili solo a te e alle persone con cui condividi il link.",
|
||||||
|
"creatingEvent": "Creazione Evento...",
|
||||||
|
"createEventButton": "Crea Evento"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"title": "{eventName} - Cactoide",
|
||||||
|
"eventTitle": "Evento - Cactoide",
|
||||||
|
"editTitle": "Modifica Evento - {eventName} - Cactoide",
|
||||||
|
"myEventsTitle": "I Miei Eventi - Cactoide",
|
||||||
|
"eventNotFoundTitle": "Evento Non Trovato",
|
||||||
|
"eventNotFoundDescription": "L'evento che stai cercando non esiste o è stato rimosso.",
|
||||||
|
"joinThisEvent": "Partecipa a Questo Evento",
|
||||||
|
"eventIsFull": "L'Evento è Pieno!",
|
||||||
|
"maximumCapacityReached": "Raggiunta la capacità massima",
|
||||||
|
"yourNameLabel": "Il tuo nome",
|
||||||
|
"yourNamePlaceholder": "Inserisci il tuo nome",
|
||||||
|
"addGuestsLabel": "Aggiungi ospiti",
|
||||||
|
"numberOfGuestsLabel": "Numero di Ospiti",
|
||||||
|
"numberOfGuestsPlaceholder": "Inserisci il numero di ospiti",
|
||||||
|
"guestsWillBeAddedAs": "Gli ospiti verranno aggiunti come \"Ospite #1 di {name}\", \"Ospite #2 di {name}\", ecc.",
|
||||||
|
"joinEventButton": "Partecipa all'Evento",
|
||||||
|
"joinEventWithGuests": "Partecipa all'Evento + {count} Ospite{plural}",
|
||||||
|
"adding": "Aggiunta...",
|
||||||
|
"attendeesTitle": "Partecipanti",
|
||||||
|
"noAttendeesYet": "Ancora nessun partecipante",
|
||||||
|
"beFirstToJoin": "Sii il primo a partecipare!",
|
||||||
|
"copyLinkButton": "Copia Link",
|
||||||
|
"copyInviteLinkButton": "Copia Link Invito",
|
||||||
|
"addToCalendarButton": "Aggiungi al Calendario",
|
||||||
|
"eventLinkCopied": "Link dell'evento copiato negli appunti!",
|
||||||
|
"inviteLinkCopied": "Link invito copiato negli appunti!",
|
||||||
|
"rsvpAddedSuccessfully": "RSVP aggiunto con successo!",
|
||||||
|
"removedRsvpSuccessfully": "RSVP rimosso con successo.",
|
||||||
|
"failedToAddRsvp": "Impossibile aggiungere RSVP",
|
||||||
|
"failedToRemoveRsvp": "Impossibile rimuovere RSVP",
|
||||||
|
"editEventTitle": "Modifica Evento",
|
||||||
|
"editEventDescription": "Aggiorna i dettagli del tuo evento",
|
||||||
|
"updatingEvent": "Aggiornamento...",
|
||||||
|
"updateEventButton": "Aggiorna Evento",
|
||||||
|
"myEventsDescription": "Gestisci i tuoi eventi creati",
|
||||||
|
"noEventsYetTitle": "Ancora Nessun Evento",
|
||||||
|
"noEventsYetDescription": "Non hai ancora creato nessun evento. Inizia creando il tuo primo evento!",
|
||||||
|
"createYourFirstEventButton": "Crea il Tuo Primo Evento",
|
||||||
|
"deleteEventTitle": "Elimina Evento",
|
||||||
|
"deleteEventDescription": "Sei sicuro di voler eliminare \"{eventName}\"? Questa azione non può essere annullata e rimuoverà tutti gli RSVP.",
|
||||||
|
"deleteButton": "Elimina",
|
||||||
|
"viewEventAriaLabel": "Visualizza evento",
|
||||||
|
"editEventAriaLabel": "Modifica evento",
|
||||||
|
"deleteEventAriaLabel": "Elimina evento",
|
||||||
|
"removeRsvpAriaLabel": "Rimuovi RSVP",
|
||||||
|
"inviteLinkExpiresAt": "Questo link scade quando l'evento inizia: {time}"
|
||||||
|
},
|
||||||
|
"discover": {
|
||||||
|
"title": "Scopri Eventi - Cactoide",
|
||||||
|
"noPublicEventsTitle": "Ancora Nessun Evento Pubblico",
|
||||||
|
"noPublicEventsDescription": "Al momento non ci sono eventi pubblici disponibili. Sii il primo a crearne uno!",
|
||||||
|
"createButton": "Crea",
|
||||||
|
"publicEventsTitle": "Eventi Pubblici ({count})",
|
||||||
|
"publicEventsDescription": "Scopri eventi creati dalla comunità",
|
||||||
|
"searchPlaceholder": "Cerca eventi per nome, luogo...",
|
||||||
|
"searchInputAriaLabel": "Input di ricerca",
|
||||||
|
"toggleFiltersAriaLabel": "Attiva/Disattiva filtri",
|
||||||
|
"typeFilterLabel": "Tipo:",
|
||||||
|
"typeFilterAll": "Tutti",
|
||||||
|
"typeFilterLimited": "Limitati",
|
||||||
|
"typeFilterUnlimited": "Illimitati",
|
||||||
|
"statusFilterLabel": "Stato:",
|
||||||
|
"statusFilterAll": "Tutti gli eventi",
|
||||||
|
"statusFilterUpcoming": "Eventi imminenti",
|
||||||
|
"statusFilterPast": "Eventi passati",
|
||||||
|
"timeFilterLabel": "Orario:",
|
||||||
|
"timeFilterAny": "Qualsiasi orario",
|
||||||
|
"timeFilterNextWeek": "Prossima settimana",
|
||||||
|
"timeFilterNextMonth": "Prossimo mese",
|
||||||
|
"sortOrderLabel": "Ordina:",
|
||||||
|
"sortOrderEarliest": "Prima i più vicini",
|
||||||
|
"sortOrderLatest": "Prima i più recenti",
|
||||||
|
"viewButton": "Visualizza",
|
||||||
|
"noEventsFoundTitle": "Nessun evento trovato",
|
||||||
|
"noEventsFoundDescription": "Prova a modificare i termini di ricerca o sfoglia tutti gli eventi"
|
||||||
|
},
|
||||||
|
"calendar": {
|
||||||
|
"addToCalendarTitle": "Aggiungi al Calendario",
|
||||||
|
"googleCalendarTitle": "Google Calendar",
|
||||||
|
"googleCalendarDescription": "Aggiungi a Google Calendar",
|
||||||
|
"microsoftOutlookTitle": "Microsoft Outlook",
|
||||||
|
"microsoftOutlookDescription": "Aggiungi a Outlook Calendar",
|
||||||
|
"downloadICalTitle": "Scarica File iCal",
|
||||||
|
"downloadICalDescription": "Scarica file .ics per qualsiasi app di calendario"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"title": "Errore - Cactoide",
|
||||||
|
"errorTitle": "Errore",
|
||||||
|
"anUnexpectedErrorOccurred": "Si è verificato un errore inaspettato.",
|
||||||
|
"homeButton": "Home"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"defaultTitle": "Cactoide -",
|
||||||
|
"defaultDescription": "Crea e gestisci gli RSVP degli eventi",
|
||||||
|
"userIdCookieText": "Il tuo UserID memorizzato come cookie:",
|
||||||
|
"firstTimeVisiting": "Prima visita. Generazione di un nuovo UserID...",
|
||||||
|
"copyright": "© 2025 Cactoide"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,10 @@
|
|||||||
"time": "Time",
|
"time": "Time",
|
||||||
"location": "Location",
|
"location": "Location",
|
||||||
"locationType": "Location Type",
|
"locationType": "Location Type",
|
||||||
|
"locationNone": "None",
|
||||||
"locationText": "Text",
|
"locationText": "Text",
|
||||||
"locationMaps": "Google Maps",
|
"locationMaps": "Google Maps",
|
||||||
|
"locationNoneDescription": "No location specified",
|
||||||
"locationTextDescription": "Enter location as plain text.",
|
"locationTextDescription": "Enter location as plain text.",
|
||||||
"locationMapsDescription": "Enter Google Maps link.",
|
"locationMapsDescription": "Enter Google Maps link.",
|
||||||
"googleMapsUrl": "Google Maps URL",
|
"googleMapsUrl": "Google Maps URL",
|
||||||
@@ -25,6 +27,8 @@
|
|||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
|
"inviteOnly": "Invite Only",
|
||||||
|
"invite-only": "Invite Only",
|
||||||
"limited": "Limited",
|
"limited": "Limited",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"capacity": "Capacity",
|
"capacity": "Capacity",
|
||||||
@@ -39,7 +43,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",
|
||||||
@@ -62,9 +65,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",
|
||||||
@@ -94,14 +99,18 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"myEvents": "My Events"
|
"myEvents": "My Events",
|
||||||
|
"instance": "Instance"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Cactoide - The RSVP site",
|
"title": "Cactoide - The RSVP site",
|
||||||
"description": "Create and manage event RSVPs. No registration required, instant sharing.",
|
"description": "Create and manage event RSVPs. No registration required, instant sharing.",
|
||||||
"mainTitle": "Cactoide(ea)",
|
"mainTitle": "Cactoide(ea)",
|
||||||
"subtitle": "The Ultimate RSVP Platform",
|
"subtitle": "The Ultimate RSVP Platform",
|
||||||
"tagline": "Create, share, and manage events with zero friction.",
|
"tagline": "A federated mobile-first event RSVP platform that lets you create events, share unique URLs, and collect RSVPs without any registration required. With built-in federation, discover and share events across a decentralized network of instances.",
|
||||||
|
"openSourceTitle": "Open Source & Self-Hostable",
|
||||||
|
"openSourceDescription": "Cactoide is open source and easily self-hostable. View the source code, contribute, or host your own instance.",
|
||||||
|
"viewOnGitHub": "View on GitHub",
|
||||||
"whyCactoideTitle": "Why Cactoide(ae)?🌵",
|
"whyCactoideTitle": "Why Cactoide(ae)?🌵",
|
||||||
"whyCactoideDescription": "Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.",
|
"whyCactoideDescription": "Like the cactus, great events bloom under any condition when managed with care. Cactoide(ae) helps you streamline RSVPs, simplify coordination, and keep every detail efficient—so your gatherings are resilient, vibrant, and unforgettable.",
|
||||||
"createEventNow": "Create Event Now",
|
"createEventNow": "Create Event Now",
|
||||||
@@ -121,6 +130,10 @@
|
|||||||
"smartLimitsDescription": "Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.",
|
"smartLimitsDescription": "Choose between unlimited RSVPs or set a limited capacity. Perfect for any event size.",
|
||||||
"effortlessSimplicityTitle": "Effortless Simplicity",
|
"effortlessSimplicityTitle": "Effortless Simplicity",
|
||||||
"effortlessSimplicityDescription": "Designed to be instantly clear and easy. No learning curve — just open, create, and go.",
|
"effortlessSimplicityDescription": "Designed to be instantly clear and easy. No learning curve — just open, create, and go.",
|
||||||
|
"inviteLinksTitle": "Invite Links",
|
||||||
|
"inviteLinksDescription": "Create invite-only events with special links. Only people with the specific invite link can RSVP, giving you full control over who can attend.",
|
||||||
|
"federationTitle": "Federation",
|
||||||
|
"federationDescription": "Connect with other Cactoide instances to discover events across the network. Share your public events and create a decentralized event discovery network.",
|
||||||
"howItWorksTitle": "How It Works",
|
"howItWorksTitle": "How It Works",
|
||||||
"step1Title": "Create Event",
|
"step1Title": "Create Event",
|
||||||
"step1Description": "Fill out a simple form with event details. Choose between limited or unlimited capacity.",
|
"step1Description": "Fill out a simple form with event details. Choose between limited or unlimited capacity.",
|
||||||
@@ -142,10 +155,12 @@
|
|||||||
"locationLabel": "Location",
|
"locationLabel": "Location",
|
||||||
"locationPlaceholder": "Enter location",
|
"locationPlaceholder": "Enter location",
|
||||||
"locationTypeLabel": "Location Type",
|
"locationTypeLabel": "Location Type",
|
||||||
|
"locationNoneOption": "None",
|
||||||
"locationTextOption": "Plain Text",
|
"locationTextOption": "Plain Text",
|
||||||
"locationMapsOption": "Google Maps",
|
"locationMapsOption": "Google Maps",
|
||||||
"locationTextDescription": "Enter location as plain text",
|
"locationNoneDescription": "No location specified.",
|
||||||
"locationMapsDescription": "Enter Google Maps link",
|
"locationTextDescription": "Enter location as plain text.",
|
||||||
|
"locationMapsDescription": "Enter Google Maps link.",
|
||||||
"googleMapsUrlLabel": "Google Maps URL",
|
"googleMapsUrlLabel": "Google Maps URL",
|
||||||
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
|
"googleMapsUrlPlaceholder": "https://maps.google.com/...",
|
||||||
"typeLabel": "Type",
|
"typeLabel": "Type",
|
||||||
@@ -156,8 +171,10 @@
|
|||||||
"visibilityLabel": "Visibility",
|
"visibilityLabel": "Visibility",
|
||||||
"publicOption": "🌍 Public",
|
"publicOption": "🌍 Public",
|
||||||
"privateOption": "🔒 Private",
|
"privateOption": "🔒 Private",
|
||||||
"publicDescription": "Public events are visible to everyone and can be discovered by others",
|
"inviteOnlyOption": "🚧 Invite Only",
|
||||||
"privateDescription": "Private events are only visible to you and people you share the link with",
|
"publicDescription": "Public events are visible to everyone and can be discovered by others.",
|
||||||
|
"privateDescription": "Private events are only visible to you and people you share the link with.",
|
||||||
|
"inviteOnlyDescription": "Event is public but requires a special invite link to attend.",
|
||||||
"creatingEvent": "Creating Event...",
|
"creatingEvent": "Creating Event...",
|
||||||
"createEventButton": "Create Event"
|
"createEventButton": "Create Event"
|
||||||
},
|
},
|
||||||
@@ -184,9 +201,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",
|
||||||
@@ -204,7 +226,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",
|
||||||
@@ -235,6 +258,21 @@
|
|||||||
"noEventsFoundTitle": "No events found",
|
"noEventsFoundTitle": "No events found",
|
||||||
"noEventsFoundDescription": "Try adjusting your search terms or browse all events"
|
"noEventsFoundDescription": "Try adjusting your search terms or browse all events"
|
||||||
},
|
},
|
||||||
|
"instance": {
|
||||||
|
"name": "Name",
|
||||||
|
"url": "URL",
|
||||||
|
"events": "Events",
|
||||||
|
"healthStatus": "Health Status",
|
||||||
|
"responseTime": "Response Time",
|
||||||
|
"notAvailable": "N/A",
|
||||||
|
"healthStatusHealthy": "healthy",
|
||||||
|
"healthStatusUnhealthy": "unhealthy",
|
||||||
|
"healthStatusUnknown": "unknown",
|
||||||
|
"description": "These are the instances that are part of the github original federation list, if you want to add your instance to the list, please open a pull request to the",
|
||||||
|
"configFile": "federation.config.js",
|
||||||
|
"file": "file.",
|
||||||
|
"noInstances": "No federation instances configured."
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"addToCalendarTitle": "Add to Calendar",
|
"addToCalendarTitle": "Add to Calendar",
|
||||||
"googleCalendarTitle": "Google Calendar",
|
"googleCalendarTitle": "Google Calendar",
|
||||||
@@ -253,7 +291,7 @@
|
|||||||
"layout": {
|
"layout": {
|
||||||
"defaultTitle": "Cactoide -",
|
"defaultTitle": "Cactoide -",
|
||||||
"defaultDescription": "Create and manage event RSVPs",
|
"defaultDescription": "Create and manage event RSVPs",
|
||||||
"userIdCookieText": "Your UserID storated as a cookie:",
|
"userIdCookieText": "Your UserID stored as a cookie:",
|
||||||
"firstTimeVisiting": "First time visiting. Generating new UserID...",
|
"firstTimeVisiting": "First time visiting. Generating new UserID...",
|
||||||
"copyright": "© 2025 Cactoide"
|
"copyright": "© 2025 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;
|
||||||
|
}
|
||||||
40
src/lib/logger.ts
Normal file
40
src/lib/logger.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import { LOG_PRETTY, LOG_LEVEL } from '$env/static/private';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (LOG_PRETTY && LOG_LEVEL) {
|
||||||
|
console.debug(
|
||||||
|
`Initializing logger with pretty logs: LOG_PRETTY: ${LOG_PRETTY} and LOG_LEVEL: ${LOG_LEVEL}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing logger', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const USE_PRETTY_LOGS = LOG_PRETTY === 'true';
|
||||||
|
|
||||||
|
const transport = USE_PRETTY_LOGS
|
||||||
|
? {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname'
|
||||||
|
},
|
||||||
|
customLevels: {
|
||||||
|
trace: 10,
|
||||||
|
debug: 20,
|
||||||
|
info: 30,
|
||||||
|
warn: 40,
|
||||||
|
error: 50,
|
||||||
|
fatal: 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: LOG_LEVEL,
|
||||||
|
transport
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Logger = typeof logger;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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 = 'text' | 'maps';
|
export type LocationType = 'none' | 'text' | 'maps';
|
||||||
|
|
||||||
export interface Event {
|
export interface Event {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +17,8 @@ export interface Event {
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
federation?: boolean; // Optional: true if event is from a federated instance
|
||||||
|
federation_url?: string; // Optional: URL of the federated instance this event came from
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RSVP {
|
export interface RSVP {
|
||||||
@@ -62,3 +64,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<!-- Error Content -->
|
<!-- Error Content -->
|
||||||
<div class="container mx-auto flex-1 px-4 py-8">
|
<div class="container mx-auto flex-1 px-4 py-8">
|
||||||
<div class="mx-auto max-w-md text-center">
|
<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="rounded-sm border border-red-500/30 bg-red-900 p-8">
|
||||||
<div class="mb-4 text-6xl text-red-400">🚨</div>
|
<div class="mb-4 text-6xl text-red-400">🚨</div>
|
||||||
<h2 class="mb-4 text-2xl font-bold text-red-400">{t('errors.errorTitle')}</h2>
|
<h2 class="mb-4 text-2xl font-bold text-red-400">{t('errors.errorTitle')}</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Navbar from '$lib/components/Navbar.svelte';
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="relative z-10">
|
<main class="relative z-10">
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
|
|||||||
11
src/routes/+page.server.ts
Normal file
11
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { PUBLIC_LANDING_INFO } from '$env/static/public';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
if (PUBLIC_LANDING_INFO === 'false') {
|
||||||
|
throw redirect(302, '/discover');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
import FeatureCard from '$lib/components/FeatureCard.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -22,10 +23,38 @@
|
|||||||
{t('home.tagline')}
|
{t('home.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Open Source Section -->
|
||||||
|
<div class="mt-8 flex items-center justify-center gap-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/polaroi8d/cactoide"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="group flex items-center gap-2 rounded-sm border-2 border-violet-500/50 px-6 py-3 text-sm font-medium transition-all duration-300 hover:scale-105 hover:border-violet-500 hover:bg-violet-500/10 md:text-base"
|
||||||
|
aria-label={t('home.viewOnGitHub')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 transition-transform group-hover:scale-110"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{t('home.viewOnGitHub')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-slate-400 md:text-base">
|
||||||
|
{t('home.openSourceDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2 class="mt-6 pt-8 text-xl md:text-2xl">
|
<h2 class="mt-6 pt-8 text-xl md:text-2xl">
|
||||||
{t('home.whyCactoideTitle')}<span class="text-violet-400"
|
{t('home.whyCactoideTitle')}<span class="text-violet-400"
|
||||||
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
|
><a href="https://en.wikipedia.org/wiki/Cactoideae" target="_blank">*</a></span
|
||||||
>?🌵
|
>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mt-4 text-lg md:text-xl">
|
<p class="mt-4 text-lg md:text-xl">
|
||||||
{t('home.whyCactoideDescription')}
|
{t('home.whyCactoideDescription')}
|
||||||
@@ -65,72 +94,54 @@
|
|||||||
<h2 class=" mb-16 text-center text-4xl font-bold">
|
<h2 class=" mb-16 text-center text-4xl font-bold">
|
||||||
{t('home.whyCactoideFeatureTitle')}
|
{t('home.whyCactoideFeatureTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
<!-- Feature 1 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="🎯"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.instantEventCreationTitle"
|
||||||
<span class="text-4xl">🎯</span>
|
descriptionKey="home.instantEventCreationDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.instantEventCreationTitle')}</h3>
|
|
||||||
<p class="">
|
|
||||||
{t('home.instantEventCreationDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 2 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="🔗"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.oneClickSharingTitle"
|
||||||
<span class="text-4xl">🔗</span>
|
descriptionKey="home.oneClickSharingDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.oneClickSharingTitle')}</h3>
|
|
||||||
<p class="">
|
|
||||||
{t('home.oneClickSharingDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 2 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="🔍"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.allInOneClarityTitle"
|
||||||
<span class="text-4xl">🔍</span>
|
descriptionKey="home.allInOneClarityDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.allInOneClarityTitle')}</h3>
|
|
||||||
<p class="">
|
|
||||||
{t('home.allInOneClarityDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 4 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="👤"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.noHassleNoSignUpsTitle"
|
||||||
<span class="text-4xl">👤</span>
|
descriptionKey="home.noHassleNoSignUpsDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.noHassleNoSignUpsTitle')}</h3>
|
|
||||||
<p class="">
|
|
||||||
{t('home.noHassleNoSignUpsDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 5 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="🛡️"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.smartLimitsTitle"
|
||||||
<span class="text-4xl">🛡️</span>
|
descriptionKey="home.smartLimitsDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.smartLimitsTitle')}</h3>
|
|
||||||
<p class="">
|
|
||||||
{t('home.smartLimitsDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Feature 5 -->
|
<FeatureCard
|
||||||
<div class="rounded-sm border p-8 text-center">
|
emoji="✨"
|
||||||
<div class="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full">
|
titleKey="home.effortlessSimplicityTitle"
|
||||||
<span class="text-4xl">✨</span>
|
descriptionKey="home.effortlessSimplicityDescription"
|
||||||
</div>
|
/>
|
||||||
<h3 class="mb-4 text-xl font-bold text-white">{t('home.effortlessSimplicityTitle')}</h3>
|
|
||||||
<p class="">
|
<FeatureCard
|
||||||
{t('home.effortlessSimplicityDescription')}
|
emoji="🎫"
|
||||||
</p>
|
titleKey="home.inviteLinksTitle"
|
||||||
</div>
|
descriptionKey="home.inviteLinksDescription"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
emoji="🌐"
|
||||||
|
titleKey="home.federationTitle"
|
||||||
|
descriptionKey="home.federationDescription"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
55
src/routes/api/federation/events/+server.ts
Normal file
55
src/routes/api/federation/events/+server.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { database } from '$lib/database/db';
|
||||||
|
import { events } from '$lib/database/schema';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
|
import { FEDERATION_INSTANCE } from '$env/static/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
if (!FEDERATION_INSTANCE) {
|
||||||
|
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all public and invite-only events ordered by creation date (newest first)
|
||||||
|
const publicEvents = await database
|
||||||
|
.select()
|
||||||
|
.from(events)
|
||||||
|
.where(eq(events.visibility, 'public'))
|
||||||
|
.orderBy(desc(events.createdAt));
|
||||||
|
|
||||||
|
// Transform events to include federation_event type
|
||||||
|
const transformedEvents = publicEvents.map((event) => ({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
date: event.date,
|
||||||
|
time: event.time,
|
||||||
|
location: event.location,
|
||||||
|
location_type: event.locationType,
|
||||||
|
location_url: event.locationUrl,
|
||||||
|
type: event.type,
|
||||||
|
federation: true,
|
||||||
|
attendee_limit: event.attendeeLimit,
|
||||||
|
visibility: event.visibility,
|
||||||
|
user_id: event.userId,
|
||||||
|
created_at: event.createdAt?.toISOString() || '',
|
||||||
|
updated_at: event.updatedAt?.toISOString() || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
return json({
|
||||||
|
events: transformedEvents,
|
||||||
|
count: transformedEvents.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching events from API');
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: 'Failed to fetch events',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/routes/api/federation/info/+server.ts
Normal file
38
src/routes/api/federation/info/+server.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { database } from '$lib/database/db';
|
||||||
|
import { events } from '$lib/database/schema';
|
||||||
|
import { eq, count } from 'drizzle-orm';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
import federationConfig from '$lib/config/federation.config.js';
|
||||||
|
|
||||||
|
import { FEDERATION_INSTANCE } from '$env/static/private';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
try {
|
||||||
|
if (!FEDERATION_INSTANCE) {
|
||||||
|
return json({ error: 'Federation API is not enabled on this instance' }, { status: 403 });
|
||||||
|
}
|
||||||
|
// Count public events
|
||||||
|
const publicEventsCount = await database
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(events)
|
||||||
|
.where(eq(events.visibility, 'public'));
|
||||||
|
|
||||||
|
const countValue = publicEventsCount[0]?.count || 0;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
name: federationConfig.name,
|
||||||
|
publicEventsCount: countValue
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching federation info from API');
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
error: 'Failed to fetch federation info',
|
||||||
|
message: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
28
src/routes/api/healthz/+server.ts
Normal file
28
src/routes/api/healthz/+server.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// src/routes/healthz/+server.ts
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { database } from '$lib/database/db';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const startTime = performance.now();
|
||||||
|
try {
|
||||||
|
await database.execute(sql`select 1`);
|
||||||
|
const responseTime = Math.round(performance.now() - startTime);
|
||||||
|
return json(
|
||||||
|
{ ok: true, responseTime, responseTimeUnit: 'ms' },
|
||||||
|
{ headers: { 'cache-control': 'no-store' } }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const responseTime = Math.round(performance.now() - startTime);
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: (err as Error)?.message,
|
||||||
|
message: 'Database unreachable.',
|
||||||
|
responseTime,
|
||||||
|
responseTimeUnit: 'ms'
|
||||||
|
},
|
||||||
|
{ status: 503, headers: { 'cache-control': 'no-store' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
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';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
// Generate a random URL-friendly ID
|
// Generate a random URL-friendly ID
|
||||||
function generateEventId(): string {
|
function generateEventId(): string {
|
||||||
@@ -21,11 +23,11 @@ export const actions: Actions = {
|
|||||||
const date = formData.get('date') as string;
|
const date = formData.get('date') as string;
|
||||||
const time = formData.get('time') as string;
|
const time = formData.get('time') as string;
|
||||||
const location = formData.get('location') as string;
|
const location = formData.get('location') as string;
|
||||||
const locationType = formData.get('location_type') as 'text' | 'maps';
|
const locationType = formData.get('location_type') as 'none' | 'text' | 'maps';
|
||||||
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
|
||||||
@@ -34,8 +36,8 @@ export const actions: Actions = {
|
|||||||
if (!name?.trim()) missingFields.push('name');
|
if (!name?.trim()) missingFields.push('name');
|
||||||
if (!date) missingFields.push('date');
|
if (!date) missingFields.push('date');
|
||||||
if (!time) missingFields.push('time');
|
if (!time) missingFields.push('time');
|
||||||
if (!location?.trim()) missingFields.push('location');
|
|
||||||
if (!locationType) missingFields.push('location_type');
|
if (!locationType) missingFields.push('location_type');
|
||||||
|
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
|
||||||
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
|
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
|
||||||
if (!userId) missingFields.push('userId');
|
if (!userId) missingFields.push('userId');
|
||||||
|
|
||||||
@@ -56,7 +58,13 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (new Date(date) < new Date()) {
|
// Check if date is in the past using local timezone
|
||||||
|
const [year, month, day] = date.split('-').map(Number);
|
||||||
|
const eventDate = new Date(year, month - 1, day);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (eventDate < today) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: 'Date cannot be in the past.',
|
error: 'Date cannot be in the past.',
|
||||||
values: {
|
values: {
|
||||||
@@ -92,6 +100,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const eventId = generateEventId();
|
const eventId = generateEventId();
|
||||||
|
|
||||||
|
// Create the event
|
||||||
await database
|
await database
|
||||||
.insert(events)
|
.insert(events)
|
||||||
.values({
|
.values({
|
||||||
@@ -99,19 +108,37 @@ export const actions: Actions = {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
date: date,
|
date: date,
|
||||||
time: time,
|
time: time,
|
||||||
location: location.trim(),
|
location: location?.trim() || '',
|
||||||
locationType: locationType,
|
locationType: locationType,
|
||||||
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
|
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
|
||||||
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);
|
logger.error({ error, eventId, userId }, 'Unexpected error creating event');
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
date: '',
|
date: '',
|
||||||
time: '',
|
time: '',
|
||||||
location: '',
|
location: '',
|
||||||
location_type: 'text',
|
location_type: 'none',
|
||||||
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> = {};
|
||||||
@@ -50,7 +50,10 @@
|
|||||||
|
|
||||||
const handleLocationTypeChange = (locationType: LocationType) => {
|
const handleLocationTypeChange = (locationType: LocationType) => {
|
||||||
eventData.location_type = locationType;
|
eventData.location_type = locationType;
|
||||||
if (locationType === 'text') {
|
if (locationType === 'none') {
|
||||||
|
eventData.location = '';
|
||||||
|
eventData.location_url = '';
|
||||||
|
} else if (locationType === 'text') {
|
||||||
eventData.location_url = '';
|
eventData.location_url = '';
|
||||||
eventData.location = '';
|
eventData.location = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -70,7 +73,7 @@
|
|||||||
<div class="flex min-h-screen flex-col">
|
<div class="flex min-h-screen flex-col">
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto flex-1 px-4 py-8">
|
<div class="container mx-auto flex-1 px-4 py-8">
|
||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-2xl">
|
||||||
<!-- Event Creation Form -->
|
<!-- Event Creation Form -->
|
||||||
<div class="rounded-sm border p-8">
|
<div class="rounded-sm border p-8">
|
||||||
<h2 class="mb-8 text-center text-3xl font-bold text-violet-400">{t('create.formTitle')}</h2>
|
<h2 class="mb-8 text-center text-3xl font-bold text-violet-400">{t('create.formTitle')}</h2>
|
||||||
@@ -136,6 +139,11 @@
|
|||||||
bind:value={eventData.date}
|
bind:value={eventData.date}
|
||||||
min={today}
|
min={today}
|
||||||
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{#if errors.date}
|
{#if errors.date}
|
||||||
@@ -168,7 +176,17 @@
|
|||||||
{t('create.locationTypeLabel')}
|
{t('create.locationTypeLabel')}
|
||||||
<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
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
||||||
|
'none'
|
||||||
|
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
||||||
|
: 'border-dark-300 text-dark-700'}"
|
||||||
|
on:click={() => handleLocationTypeChange('none')}
|
||||||
|
>
|
||||||
|
{t('create.locationNoneOption')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
||||||
@@ -190,52 +208,56 @@
|
|||||||
{t('create.locationMapsOption')}
|
{t('create.locationMapsOption')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400">
|
<p class="mt-2 text-xs text-slate-400 italic">
|
||||||
{eventData.location_type === 'text'
|
{eventData.location_type === 'none'
|
||||||
? t('create.locationTextDescription')
|
? t('create.locationNoneDescription')
|
||||||
: t('create.locationMapsDescription')}
|
: eventData.location_type === 'text'
|
||||||
|
? t('create.locationTextDescription')
|
||||||
|
: t('create.locationMapsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location Input -->
|
<!-- Location Input (only show when not 'none') -->
|
||||||
<div>
|
{#if eventData.location_type !== 'none'}
|
||||||
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
|
<div>
|
||||||
{eventData.location_type === 'text'
|
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||||
? t('create.locationLabel')
|
{eventData.location_type === 'text'
|
||||||
: t('create.googleMapsUrlLabel')}
|
? t('create.locationLabel')
|
||||||
<span class="text-red-400">{t('common.required')}</span>
|
: t('create.googleMapsUrlLabel')}
|
||||||
</label>
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
{#if eventData.location_type === 'text'}
|
</label>
|
||||||
<input
|
{#if eventData.location_type === 'text'}
|
||||||
id="location"
|
<input
|
||||||
name="location"
|
id="location"
|
||||||
type="text"
|
name="location"
|
||||||
bind:value={eventData.location}
|
type="text"
|
||||||
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
bind:value={eventData.location}
|
||||||
placeholder={t('create.locationPlaceholder')}
|
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
||||||
maxlength="200"
|
placeholder={t('create.locationPlaceholder')}
|
||||||
required
|
maxlength="200"
|
||||||
/>
|
required
|
||||||
{:else}
|
/>
|
||||||
<input
|
{:else}
|
||||||
id="location_url"
|
<input
|
||||||
name="location_url"
|
id="location_url"
|
||||||
type="url"
|
name="location_url"
|
||||||
bind:value={eventData.location_url}
|
type="url"
|
||||||
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
bind:value={eventData.location_url}
|
||||||
placeholder={t('create.googleMapsUrlPlaceholder')}
|
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
||||||
maxlength="500"
|
placeholder={t('create.googleMapsUrlPlaceholder')}
|
||||||
required
|
maxlength="500"
|
||||||
/>
|
required
|
||||||
{/if}
|
/>
|
||||||
{#if errors.location}
|
{/if}
|
||||||
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
|
{#if errors.location}
|
||||||
{/if}
|
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
|
||||||
{#if errors.location_url}
|
{/if}
|
||||||
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
|
{#if errors.location_url}
|
||||||
{/if}
|
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
<div>
|
<div>
|
||||||
@@ -274,7 +296,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<label for="limit" class="text-dark-800 mb-3 block text-sm font-semibold">
|
<label for="limit" class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||||
{t('create.attendeeLimitLabel')}
|
{t('create.attendeeLimitLabel')}
|
||||||
{t('common.required')}
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="attendee_limit"
|
id="attendee_limit"
|
||||||
@@ -300,13 +322,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')}
|
||||||
@@ -321,11 +343,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">
|
<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>
|
||||||
@@ -342,7 +376,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
class="hover:bg-violet-400/70'l 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'l flex-2 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 isSubmitting}
|
{#if isSubmitting}
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
|
|||||||
@@ -1,39 +1,53 @@
|
|||||||
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';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
import { fetchAllFederatedEvents } from '$lib/fetchFederatedEvents';
|
||||||
|
|
||||||
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 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
|
||||||
const transformedEvents = publicEvents.map((event) => ({
|
const transformedEvents = publicEvents.map((event) => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
date: event.date, // Already in 'YYYY-MM-DD' format
|
date: event.date,
|
||||||
time: event.time, // Already in 'HH:MM:SS' format
|
time: event.time,
|
||||||
location: event.location,
|
location: event.location,
|
||||||
location_type: event.locationType,
|
location_type: event.locationType,
|
||||||
location_url: event.locationUrl,
|
location_url: event.locationUrl,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
attendee_limit: event.attendeeLimit, // Note: schema uses camelCase
|
attendee_limit: event.attendeeLimit,
|
||||||
visibility: event.visibility,
|
visibility: event.visibility,
|
||||||
user_id: event.userId, // Note: schema uses camelCase
|
user_id: event.userId,
|
||||||
created_at: event.createdAt?.toISOString(),
|
created_at: event.createdAt?.toISOString(),
|
||||||
updated_at: event.updatedAt?.toISOString()
|
updated_at: event.updatedAt?.toISOString(),
|
||||||
|
federation: false // Add false for local events
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Fetch federated events from federation.config.js
|
||||||
|
let federatedEvents: typeof transformedEvents = [];
|
||||||
|
try {
|
||||||
|
federatedEvents = await fetchAllFederatedEvents();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error fetching federated events, continuing with local events only');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge local and federated events
|
||||||
|
const allEvents = [...transformedEvents, ...federatedEvents];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
events: transformedEvents
|
events: allEvents
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading public events:', error);
|
logger.error({ error }, 'Error loading events');
|
||||||
|
|
||||||
// Return empty array on error to prevent page crash
|
// Return empty array on error to prevent page crash
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -257,9 +267,15 @@
|
|||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each filteredEvents as event, i (i)}
|
{#each filteredEvents as event, i (i)}
|
||||||
<div class="rounded-sm border border-slate-200 p-6 shadow-sm">
|
{@const isFederated = event.federation === true}
|
||||||
<div class="mb-4">
|
<div
|
||||||
<h3 class="mb-2 text-xl font-bold text-slate-300">{event.name}</h3>
|
class="flex flex-col rounded-sm border border-slate-200 bg-slate-800/50
|
||||||
|
p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex-1">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold text-slate-300">{event.name}</h3>
|
||||||
|
</div>
|
||||||
<div class="space-y-2 text-sm text-slate-500">
|
<div class="space-y-2 text-sm text-slate-500">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<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">
|
||||||
@@ -289,39 +305,73 @@
|
|||||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{#if event.location_type === 'maps' && event.location_url}
|
{#if event.location_type === 'none'}
|
||||||
|
<span>N/A</span>
|
||||||
|
{:else if event.location_type === 'maps' && event.location_url}
|
||||||
<a
|
<a
|
||||||
href={event.location_url}
|
href={event.location_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
|
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
|
||||||
>
|
>
|
||||||
{event.location}
|
{t('create.locationMapsOption')}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
{#if isFederated && event.federation_url}
|
||||||
<span
|
<div class="flex items-center space-x-2">
|
||||||
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
<span
|
||||||
'limited'
|
class="rounded-sm border border-blue-500 px-2 py-1 text-xs
|
||||||
? 'border-amber-600 text-amber-600'
|
font-medium text-blue-500"
|
||||||
: 'border-teal-500 text-teal-500'}"
|
>
|
||||||
>
|
{event.federation_url}
|
||||||
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
</span>
|
||||||
</span>
|
</div>{:else}
|
||||||
</div>
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm border px-2 py-1 text-xs font-medium {event.type ===
|
||||||
|
'limited'
|
||||||
|
? 'border-amber-600 text-amber-600'
|
||||||
|
: 'border-teal-500 text-teal-500'}"
|
||||||
|
>
|
||||||
|
{event.type === 'limited' ? t('common.limited') : t('common.unlimited')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm border px-2 py-1 text-xs font-medium {event.visibility ===
|
||||||
|
'public'
|
||||||
|
? 'border-teal-500 text-teal-500'
|
||||||
|
: 'border-amber-600 text-amber-600'}"
|
||||||
|
>
|
||||||
|
{event.visibility === 'public'
|
||||||
|
? t('common.public')
|
||||||
|
: t('common.inviteOnly')}
|
||||||
|
</span>
|
||||||
|
</div>{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="mt-auto flex">
|
||||||
<button
|
{#if isFederated && event.federation_url}
|
||||||
on:click={() => goto(`/event/${event.id}`)}
|
<a
|
||||||
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
|
href="{event.federation_url}/event/{event.id}"
|
||||||
>
|
target="_blank"
|
||||||
{t('discover.viewButton')}
|
rel="noopener noreferrer"
|
||||||
</button>
|
class="flex-1 rounded-sm border-2 border-blue-500 bg-blue-400/20 px-4 py-2 text-center font-semibold duration-200 hover:bg-blue-400/70"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
on:click={() => goto(`/event/${event.id}`)}
|
||||||
|
class="flex-1 rounded-sm border-2 border-violet-500 bg-violet-400/20 px-4 py-2 font-semibold duration-200 hover:bg-violet-400/70"
|
||||||
|
>
|
||||||
|
{t('discover.viewButton')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { events } from '$lib/database/schema';
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
export const load = async ({ cookies }) => {
|
export const load = async ({ cookies }) => {
|
||||||
const userId = cookies.get('cactoideUserId');
|
const userId = cookies.get('cactoideUserId');
|
||||||
@@ -36,7 +37,7 @@ export const load = async ({ cookies }) => {
|
|||||||
|
|
||||||
return { events: transformedEvents };
|
return { events: transformedEvents };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user events:', error);
|
logger.error({ error, userId }, 'Error loading user events');
|
||||||
return { events: [] };
|
return { events: [] };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -68,7 +69,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting event:', error);
|
logger.error({ error, eventId, userId }, 'Error deleting event');
|
||||||
return fail(500, { error: 'Failed to delete event' });
|
return fail(500, { error: 'Failed to delete event' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,14 +126,16 @@
|
|||||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
{#if event.location_type === 'maps' && event.location_url}
|
{#if event.location_type === 'none'}
|
||||||
|
<span>N/A</span>
|
||||||
|
{:else if event.location_type === 'maps' && event.location_url}
|
||||||
<a
|
<a
|
||||||
href={event.location_url}
|
href={event.location_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
|
class="text-slate-500 transition-colors duration-200 hover:text-slate-300"
|
||||||
>
|
>
|
||||||
{event.location}
|
Google Maps
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span>{event.location}</span>
|
<span>{event.location}</span>
|
||||||
@@ -154,7 +156,8 @@
|
|||||||
? 'border-green-300 text-green-400'
|
? 'border-green-300 text-green-400'
|
||||||
: 'border-orange-300 text-orange-400'}"
|
: 'border-orange-300 text-orange-400'}"
|
||||||
>
|
>
|
||||||
{event.visibility === 'public' ? t('common.public') : t('common.private')}
|
<!-- TODO(polaroi8d): replace with something better solution; message.json using this, beacuse of common.invite-only works here -->
|
||||||
|
{t(`common.${event.visibility}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2"></div>
|
<div class="flex items-center space-x-2"></div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { events, rsvps } from '$lib/database/schema';
|
|||||||
import { eq, asc } from 'drizzle-orm';
|
import { eq, asc } from 'drizzle-orm';
|
||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
const eventId = params.id;
|
const eventId = params.id;
|
||||||
@@ -25,6 +26,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,
|
||||||
@@ -60,7 +71,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Response) throw err; // This is the 404 error
|
if (err instanceof Response) throw err; // This is the 404 error
|
||||||
|
|
||||||
console.error('Error loading event:', err);
|
logger.error({ error: err, eventId }, 'Error loading event');
|
||||||
throw error(500, 'Failed to load event');
|
throw error(500, 'Failed to load event');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -85,17 +96,23 @@ 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));
|
||||||
|
|
||||||
// Calculate total attendees (including guests)
|
// Calculate remaining spots and ensure main attendee + guests fit
|
||||||
const totalAttendees = currentRSVPs.length + numberOfGuests;
|
const newAttendeesCount = 1 + numberOfGuests;
|
||||||
|
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||||
|
|
||||||
// Check if event is full (for limited type events)
|
// Check if event is full (for limited type events)
|
||||||
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||||
if (totalAttendees > eventData.attendeeLimit) {
|
if (newAttendeesCount > remainingSpots) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
error: `Event capacity exceeded. You're trying to add ${numberOfGuests + 1} attendees (including yourself), but only ${eventData.attendeeLimit - currentRSVPs.length} spots remain.`
|
error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} remain.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -130,7 +147,7 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
return { success: true, type: 'add' };
|
return { success: true, type: 'add' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error adding RSVP:', err);
|
logger.error({ error: err, eventId, userId, name }, 'Error adding RSVP');
|
||||||
return fail(500, { error: 'Failed to add RSVP' });
|
return fail(500, { error: 'Failed to add RSVP' });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -148,7 +165,7 @@ export const actions: Actions = {
|
|||||||
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
|
await database.delete(rsvps).where(eq(rsvps.id, rsvpId));
|
||||||
return { success: true, type: 'remove' };
|
return { success: true, type: 'remove' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error removing RSVP:', err);
|
logger.error({ error: err, rsvpId }, 'Error removing RSVP');
|
||||||
return fail(500, { error: 'Failed to remove RSVP' });
|
return fail(500, { error: 'Failed to remove RSVP' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -106,7 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if event}
|
{:else if event}
|
||||||
<div class="mx-auto max-w-md space-y-6">
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
<!-- Event Details Card -->
|
<!-- Event Details Card -->
|
||||||
|
|
||||||
<div class="rounded-sm border p-6 shadow-2xl">
|
<div class="rounded-sm border p-6 shadow-2xl">
|
||||||
@@ -136,7 +165,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location -->
|
<!-- Location (only show when not 'none') -->
|
||||||
<div class="flex items-center space-x-3 text-violet-400">
|
<div class="flex items-center space-x-3 text-violet-400">
|
||||||
<div class="flex h-8 w-8 items-center justify-center rounded-sm">
|
<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">
|
<svg class=" h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -155,14 +184,16 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{#if event.location_type === 'maps' && event.location_url}
|
{#if event.location_type === 'none'}
|
||||||
|
<p class="font-semibold text-white">N/A</p>
|
||||||
|
{:else if event.location_type === 'maps' && event.location_url}
|
||||||
<a
|
<a
|
||||||
href={event.location_url}
|
href={event.location_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
|
class="font-semibold text-white transition-colors duration-200 hover:text-violet-300"
|
||||||
>
|
>
|
||||||
{event.location}
|
{t('create.locationMapsOption')}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="font-semibold text-white">{event.location}</p>
|
<p class="font-semibold text-white">{event.location}</p>
|
||||||
@@ -206,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>
|
||||||
@@ -314,101 +351,113 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Attendees List -->
|
<!-- Attendees List -->
|
||||||
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
{#if event.visibility !== 'invite-only'}
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="rounded-sm border p-6 shadow-2xl backdrop-blur-sm">
|
||||||
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<span class="text-2xl font-bold">{rsvps.length}</span>
|
<h3 class=" text-xl font-bold">{t('event.attendeesTitle')}</h3>
|
||||||
</div>
|
<span class="text-2xl font-bold">{rsvps.length}</span>
|
||||||
|
|
||||||
{#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>
|
</div>
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
{#if rsvps.length === 0}
|
||||||
{#each rsvps as attendee, i (i)}
|
<div class="text-dark-400 py-8 text-center">
|
||||||
<div
|
<p>{t('event.noAttendeesYet')}</p>
|
||||||
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
|
<p class="mt-1 text-sm">{t('event.beFirstToJoin')}</p>
|
||||||
>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
{:else}
|
||||||
<div
|
<div class="space-y-3">
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
|
{#each rsvps as attendee, i (i)}
|
||||||
"'s Guest"
|
<div
|
||||||
)
|
class="flex items-center justify-between rounded-sm border border-white/20 p-3"
|
||||||
? 'text-white-400 bg-violet-500/40'
|
>
|
||||||
: 'bg-violet-500/20 text-violet-400'}"
|
<div class="flex items-center space-x-3">
|
||||||
>
|
<div
|
||||||
{attendee.name.charAt(0).toUpperCase()}
|
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold {attendee.name.includes(
|
||||||
</div>
|
"'s Guest"
|
||||||
<div>
|
)
|
||||||
<p
|
? 'text-white-400 bg-violet-500/40'
|
||||||
class="font-medium text-white {attendee.name.includes("'s Guest")
|
: 'bg-violet-500/20 text-violet-400'}"
|
||||||
? 'text-amber-300'
|
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
{attendee.name}
|
{attendee.name.charAt(0).toUpperCase()}
|
||||||
</p>
|
</div>
|
||||||
<p class="text-xs text-violet-400">
|
<div>
|
||||||
{(() => {
|
<p
|
||||||
const date = new Date(attendee.created_at);
|
class="font-medium text-white {attendee.name.includes("'s Guest")
|
||||||
const year = date.getFullYear();
|
? 'text-amber-300'
|
||||||
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');
|
{attendee.name}
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
</p>
|
||||||
return `${year}/${month}/${day} ${hours}:${minutes}`;
|
<p class="text-xs text-violet-400">
|
||||||
})()}
|
{(() => {
|
||||||
</p>
|
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>
|
</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>
|
</div>
|
||||||
|
{/each}
|
||||||
{#if attendee.user_id === currentUserId}
|
</div>
|
||||||
<form
|
{/if}
|
||||||
method="POST"
|
</div>
|
||||||
action="?/removeRSVP"
|
{/if}
|
||||||
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 -->
|
<!-- Action Buttons -->
|
||||||
<div class="max-w-2xl space-y-3">
|
<div class="max-w-2xl space-y-3">
|
||||||
<button
|
{#if event.visibility !== 'invite-only'}
|
||||||
on:click={copyEventLink}
|
<button
|
||||||
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"
|
on:click={copyEventLink}
|
||||||
>
|
disabled={!isEventCreator}
|
||||||
{t('event.copyLinkButton')}
|
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
|
||||||
</button>
|
? 'border-violet-500 bg-violet-400/20 hover:bg-violet-400/70'
|
||||||
|
: 'cursor-not-allowed border-gray-500 bg-gray-600/20 hover:bg-gray-600/30'}"
|
||||||
|
>
|
||||||
|
{t('event.copyLinkButton')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<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"
|
||||||
@@ -434,24 +483,32 @@
|
|||||||
|
|
||||||
<!-- 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 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 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 p-4 text-yellow-400"
|
||||||
|
>
|
||||||
|
{t('event.eventLinkCopied')}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- fallback -->
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div
|
<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"
|
class="fixed right-4 bottom-4 z-40 w-128 rounded-sm border border-red-500/30 bg-red-900 p-4 text-red-400"
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
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';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
const eventId = params.id;
|
const eventId = params.id;
|
||||||
@@ -23,8 +24,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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,7 +76,7 @@ export const actions: Actions = {
|
|||||||
const date = formData.get('date') as string;
|
const date = formData.get('date') as string;
|
||||||
const time = formData.get('time') as string;
|
const time = formData.get('time') as string;
|
||||||
const location = formData.get('location') as string;
|
const location = formData.get('location') as string;
|
||||||
const locationType = formData.get('location_type') as 'text' | 'maps';
|
const locationType = formData.get('locationType') as string;
|
||||||
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;
|
||||||
@@ -65,8 +88,8 @@ export const actions: Actions = {
|
|||||||
if (!name?.trim()) missingFields.push('name');
|
if (!name?.trim()) missingFields.push('name');
|
||||||
if (!date) missingFields.push('date');
|
if (!date) missingFields.push('date');
|
||||||
if (!time) missingFields.push('time');
|
if (!time) missingFields.push('time');
|
||||||
if (!location?.trim()) missingFields.push('location');
|
|
||||||
if (!locationType) missingFields.push('location_type');
|
if (!locationType) missingFields.push('location_type');
|
||||||
|
if (locationType === 'text' && !location?.trim()) missingFields.push('location');
|
||||||
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
|
if (locationType === 'maps' && !locationUrl?.trim()) missingFields.push('location_url');
|
||||||
|
|
||||||
if (missingFields.length > 0) {
|
if (missingFields.length > 0) {
|
||||||
@@ -86,8 +109,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
|
||||||
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);
|
||||||
|
|
||||||
@@ -124,7 +148,6 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the event
|
// Update the event
|
||||||
await database
|
await database
|
||||||
.update(events)
|
.update(events)
|
||||||
@@ -132,8 +155,8 @@ export const actions: Actions = {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
date: date,
|
date: date,
|
||||||
time: time,
|
time: time,
|
||||||
location: location.trim(),
|
location: location?.trim() || '',
|
||||||
locationType: locationType,
|
locationType: locationType as 'none' | 'text' | 'maps',
|
||||||
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
|
locationUrl: locationType === 'maps' ? locationUrl?.trim() : null,
|
||||||
type: type,
|
type: type,
|
||||||
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
|
attendeeLimit: type === 'limited' ? parseInt(attendeeLimit) : null,
|
||||||
@@ -142,7 +165,7 @@ export const actions: Actions = {
|
|||||||
})
|
})
|
||||||
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
|
.where(and(eq(events.id, eventId), eq(events.userId, userId)))
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Unexpected error updating event', error);
|
logger.error({ error, eventId, userId }, 'Unexpected error updating event');
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { EventType, LocationType } from '$lib/types';
|
import type { CreateEventData, EventType, LocationType } from '$lib/types';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { t } from '$lib/i18n/i18n.js';
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
@@ -7,20 +7,24 @@
|
|||||||
export let data;
|
export let data;
|
||||||
export let form;
|
export let form;
|
||||||
|
|
||||||
let eventData = {
|
let eventData: CreateEventData = {
|
||||||
name: data.event.name,
|
name: data.event.name,
|
||||||
date: data.event.date,
|
date: data.event.date,
|
||||||
time: data.event.time,
|
time: data.event.time,
|
||||||
location: data.event.location,
|
location: data.event.location,
|
||||||
location_type: data.event.locationType || 'text',
|
location_type: data.event.locationType || 'none',
|
||||||
location_url: data.event.locationUrl || '',
|
location_url: data.event.locationUrl || '',
|
||||||
type: data.event.type,
|
type: data.event.type,
|
||||||
attendee_limit: data.event.attendeeLimit,
|
attendee_limit: data.event.attendeeLimit || undefined,
|
||||||
visibility: data.event.visibility
|
visibility: data.event.visibility
|
||||||
};
|
};
|
||||||
|
|
||||||
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];
|
||||||
@@ -40,20 +44,23 @@
|
|||||||
attendee_limit: (values as any).attendee_limit
|
attendee_limit: (values as any).attendee_limit
|
||||||
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
parseInt(String((values as any).attendee_limit))
|
parseInt(String((values as any).attendee_limit))
|
||||||
: null
|
: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTypeChange = (type: EventType) => {
|
const handleTypeChange = (type: EventType) => {
|
||||||
eventData.type = type;
|
eventData.type = type;
|
||||||
if (type === 'unlimited') {
|
if (type === 'unlimited') {
|
||||||
eventData.attendee_limit = null;
|
eventData.attendee_limit = undefined;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocationTypeChange = (locationType: LocationType) => {
|
const handleLocationTypeChange = (locationType: LocationType) => {
|
||||||
eventData.location_type = locationType;
|
eventData.location_type = locationType;
|
||||||
if (locationType === 'text') {
|
if (locationType === 'none') {
|
||||||
|
eventData.location = '';
|
||||||
|
eventData.location_url = '';
|
||||||
|
} else if (locationType === 'text') {
|
||||||
eventData.location_url = '';
|
eventData.location_url = '';
|
||||||
eventData.location = '';
|
eventData.location = '';
|
||||||
} else {
|
} else {
|
||||||
@@ -64,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>
|
||||||
@@ -73,7 +98,7 @@
|
|||||||
<div class="flex min-h-screen flex-col">
|
<div class="flex min-h-screen flex-col">
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto flex-1 px-4 py-8">
|
<div class="container mx-auto flex-1 px-4 py-8">
|
||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-2xl">
|
||||||
<!-- Event Edit Form -->
|
<!-- Event Edit Form -->
|
||||||
<div class="rounded-sm border p-8">
|
<div class="rounded-sm border p-8">
|
||||||
<div class="mb-8 text-center">
|
<div class="mb-8 text-center">
|
||||||
@@ -137,6 +162,11 @@
|
|||||||
bind:value={eventData.date}
|
bind:value={eventData.date}
|
||||||
min={today}
|
min={today}
|
||||||
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
class="border-dark-300 w-full rounded-sm border-2 bg-white px-4 py-3 text-slate-900 shadow-sm transition-all duration-200"
|
||||||
|
on:keydown={(e) => {
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{#if errors.date}
|
{#if errors.date}
|
||||||
@@ -164,12 +194,25 @@
|
|||||||
|
|
||||||
<!-- Location Type -->
|
<!-- Location Type -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Hidden input to submit locationType value -->
|
||||||
|
<input type="hidden" name="locationType" bind:value={eventData.location_type} />
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<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('create.locationTypeLabel')}
|
{t('create.locationTypeLabel')}
|
||||||
<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
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
||||||
|
'none'
|
||||||
|
? ' border-violet-500 bg-violet-400/20 font-semibold hover:bg-violet-400/70'
|
||||||
|
: 'border-dark-300 text-dark-700'}"
|
||||||
|
on:click={() => handleLocationTypeChange('none')}
|
||||||
|
>
|
||||||
|
{t('create.locationNoneOption')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
class="rounded-sm border-2 px-4 py-3 font-medium transition-all duration-200 {eventData.location_type ===
|
||||||
@@ -192,54 +235,61 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-slate-400">
|
<p class="mt-2 text-xs text-slate-400">
|
||||||
{eventData.location_type === 'text'
|
{eventData.location_type === 'none'
|
||||||
? t('create.locationTextDescription')
|
? t('create.locationNoneDescription')
|
||||||
: t('create.locationMapsDescription')}
|
: eventData.location_type === 'text'
|
||||||
|
? t('create.locationTextDescription')
|
||||||
|
: t('create.locationMapsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location Input -->
|
<!-- Location Input (only show when not 'none') -->
|
||||||
<div>
|
{#if eventData.location_type !== 'none'}
|
||||||
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
|
<div>
|
||||||
{eventData.location_type === 'text'
|
<label for="location" class="text-dark-800 mb-3 block text-sm font-semibold">
|
||||||
? t('create.locationTypeLabel')
|
{eventData.location_type === 'text'
|
||||||
: t('create.googleMapsUrlLabel')}
|
? t('create.locationLabel')
|
||||||
<span class="text-red-400">{t('common.required')}</span>
|
: t('create.googleMapsUrlLabel')}
|
||||||
</label>
|
<span class="text-red-400">{t('common.required')}</span>
|
||||||
{#if eventData.location_type === 'text'}
|
</label>
|
||||||
<input
|
{#if eventData.location_type === 'text'}
|
||||||
id="location"
|
<input
|
||||||
name="location"
|
id="location"
|
||||||
type="text"
|
name="location"
|
||||||
bind:value={eventData.location}
|
type="text"
|
||||||
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
bind:value={eventData.location}
|
||||||
placeholder={t('create.locationPlaceholder')}
|
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
||||||
maxlength="200"
|
placeholder={t('create.locationPlaceholder')}
|
||||||
required
|
maxlength="200"
|
||||||
/>
|
required
|
||||||
{:else}
|
/>
|
||||||
<input
|
{:else}
|
||||||
id="location_url"
|
<input
|
||||||
name="location_url"
|
id="location_url"
|
||||||
type="url"
|
name="location_url"
|
||||||
bind:value={eventData.location_url}
|
type="url"
|
||||||
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
bind:value={eventData.location_url}
|
||||||
placeholder={t('create.googleMapsUrlPlaceholder')}
|
class="border-dark-300 placeholder-dark-500 w-full rounded-sm border-2 px-4 py-3 text-slate-900 shadow-sm transition-all"
|
||||||
maxlength="500"
|
placeholder={t('create.googleMapsUrlPlaceholder')}
|
||||||
required
|
maxlength="500"
|
||||||
/>
|
required
|
||||||
{/if}
|
/>
|
||||||
{#if errors.location}
|
{/if}
|
||||||
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
|
{#if errors.location}
|
||||||
{/if}
|
<p class="mt-2 text-sm font-medium text-red-600">{errors.location}</p>
|
||||||
{#if errors.location_url}
|
{/if}
|
||||||
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
|
{#if errors.location_url}
|
||||||
{/if}
|
<p class="mt-2 text-sm font-medium text-red-600">{errors.location_url}</p>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Hidden input to submit type value -->
|
||||||
|
<input type="hidden" name="type" bind:value={eventData.type} />
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<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.type')} <span class="text-red-400">{t('common.required')}</span>
|
{t('common.type')} <span class="text-red-400">{t('common.required')}</span>
|
||||||
@@ -294,11 +344,14 @@
|
|||||||
|
|
||||||
<!-- Event Visibility -->
|
<!-- Event Visibility -->
|
||||||
<div>
|
<div>
|
||||||
|
<!-- Hidden input to submit visibility value -->
|
||||||
|
<input type="hidden" name="visibility" bind:value={eventData.visibility} />
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<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 ===
|
||||||
@@ -319,15 +372,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
|
||||||
@@ -357,3 +454,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 p-4 text-yellow-400"
|
||||||
|
>
|
||||||
|
{t('event.inviteLinkCopied')}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
224
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal file
224
src/routes/event/[id]/invite/[token]/+page.server.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
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 remaining spots and ensure main attendee + guests fit
|
||||||
|
const newAttendeesCount = 1 + numberOfGuests;
|
||||||
|
const remainingSpots = (eventData.attendeeLimit ?? 0) - currentRSVPs.length;
|
||||||
|
|
||||||
|
// Check if event is full (for limited type events)
|
||||||
|
if (eventData.type === 'limited' && eventData.attendeeLimit) {
|
||||||
|
if (newAttendeesCount > remainingSpots) {
|
||||||
|
return fail(400, {
|
||||||
|
error: `Event capacity exceeded. You're trying to add ${newAttendeesCount} attendee${newAttendeesCount === 1 ? '' : 's'} (including yourself), but only ${remainingSpots} spot${remainingSpots === 1 ? '' : 's'} 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 bg-green-900 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 bg-yellow-900 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 bg-red-900 p-4 text-red-400"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// src/routes/healthz/+server.ts
|
|
||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import { database } from '$lib/database/db';
|
|
||||||
import { sql } from 'drizzle-orm';
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
await database.execute(sql`select 1`);
|
|
||||||
return json({ ok: true }, { headers: { 'cache-control': 'no-store' } });
|
|
||||||
} catch (err) {
|
|
||||||
return json(
|
|
||||||
{ ok: false, error: (err as Error)?.message, message: 'Database unreachable.' },
|
|
||||||
{ status: 503, headers: { 'cache-control': 'no-store' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
119
src/routes/instance/+page.server.ts
Normal file
119
src/routes/instance/+page.server.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { logger } from '$lib/logger';
|
||||||
|
import federationConfig from '$lib/config/federation.config.js';
|
||||||
|
|
||||||
|
interface InstanceInfo {
|
||||||
|
name: string;
|
||||||
|
publicEventsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
ok: boolean;
|
||||||
|
responseTime?: number;
|
||||||
|
responseTimeUnit?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstanceData {
|
||||||
|
url: string;
|
||||||
|
name: string | null;
|
||||||
|
events: number | null;
|
||||||
|
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
responseTime: number | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInstanceInfo(instanceUrl: string): Promise<InstanceInfo | null> {
|
||||||
|
try {
|
||||||
|
const apiUrl = `http://${instanceUrl}/api/federation/info`;
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch instance info');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as InstanceInfo;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
'Error fetching instance info'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealthStatus(instanceUrl: string): Promise<HealthStatus | null> {
|
||||||
|
try {
|
||||||
|
const apiUrl = `http://${instanceUrl}/api/healthz`;
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(10000) // 10 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn({ apiUrl, status: response.status }, 'Failed to fetch health status');
|
||||||
|
return { ok: false, error: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as HealthStatus;
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ instanceUrl, error: error instanceof Error ? error.message : 'Unknown error' },
|
||||||
|
'Error fetching health status'
|
||||||
|
);
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
try {
|
||||||
|
const instances = federationConfig.instances || [];
|
||||||
|
|
||||||
|
// Fetch data from all instances in parallel
|
||||||
|
const instanceDataPromises = instances.map(async (instance): Promise<InstanceData> => {
|
||||||
|
const [info, health] = await Promise.all([
|
||||||
|
fetchInstanceInfo(instance.url),
|
||||||
|
fetchHealthStatus(instance.url)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const responseTime = health?.responseTime ?? null;
|
||||||
|
const healthStatus: 'healthy' | 'unhealthy' | 'unknown' = health?.ok
|
||||||
|
? 'healthy'
|
||||||
|
: health === null
|
||||||
|
? 'unknown'
|
||||||
|
: 'unhealthy';
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: instance.url,
|
||||||
|
name: info?.name ?? null,
|
||||||
|
events: info?.publicEventsCount ?? null,
|
||||||
|
healthStatus,
|
||||||
|
responseTime,
|
||||||
|
error: health?.error
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const instanceData = await Promise.all(instanceDataPromises);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instances: instanceData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error loading instance data');
|
||||||
|
return {
|
||||||
|
instances: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
145
src/routes/instance/+page.svelte
Normal file
145
src/routes/instance/+page.svelte
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from '$lib/i18n/i18n.js';
|
||||||
|
|
||||||
|
interface InstanceData {
|
||||||
|
url: string;
|
||||||
|
name: string | null;
|
||||||
|
events: number | null;
|
||||||
|
healthStatus: 'healthy' | 'unhealthy' | 'unknown';
|
||||||
|
responseTime: number | null;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePageData = {
|
||||||
|
instances: InstanceData[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export let data: InstancePageData;
|
||||||
|
|
||||||
|
function getStatusColor(responseTime: number | null): string {
|
||||||
|
if (responseTime === null) return 'bg-gray-400';
|
||||||
|
if (responseTime < 10) return 'bg-green-500';
|
||||||
|
if (responseTime <= 30) return 'bg-yellow-500';
|
||||||
|
return 'bg-red-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResponseTime(responseTime: number | null): string {
|
||||||
|
if (responseTime === null) return t('instance.notAvailable');
|
||||||
|
return `${responseTime} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHealthStatusText(status: 'healthy' | 'unhealthy' | 'unknown'): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy':
|
||||||
|
return t('instance.healthStatusHealthy');
|
||||||
|
case 'unhealthy':
|
||||||
|
return t('instance.healthStatusUnhealthy');
|
||||||
|
case 'unknown':
|
||||||
|
return t('instance.healthStatusUnknown');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 py-16 text-white">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full rounded-lg border border-slate-600 bg-slate-800/50 shadow-sm">
|
||||||
|
<thead class="bg-slate-800">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('instance.name')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('instance.url')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('instance.events')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('instance.healthStatus')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-slate-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('instance.responseTime')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-700">
|
||||||
|
{#each data.instances as instance, i (i)}
|
||||||
|
<tr class="hover:bg-slate-700/50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm font-medium text-slate-300">
|
||||||
|
{instance.name || t('instance.notAvailable')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href="http://{instance.url}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-slate-400 hover:text-violet-300/80"
|
||||||
|
>
|
||||||
|
{instance.url}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-slate-300">
|
||||||
|
{instance.events !== null ? instance.events : t('instance.notAvailable')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
|
||||||
|
instance.responseTime
|
||||||
|
)}"
|
||||||
|
title={getHealthStatusText(instance.healthStatus)}
|
||||||
|
></span>
|
||||||
|
<span class="text-sm text-slate-300 capitalize">
|
||||||
|
{getHealthStatusText(instance.healthStatus)}
|
||||||
|
</span>
|
||||||
|
{#if instance.error}
|
||||||
|
<span class="ml-2 text-xs text-slate-500">({instance.error})</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span
|
||||||
|
class="mr-2 inline-block h-3 w-3 rounded-full {getStatusColor(
|
||||||
|
instance.responseTime
|
||||||
|
)}"
|
||||||
|
></span>
|
||||||
|
<span class="text-sm text-slate-300">
|
||||||
|
{formatResponseTime(instance.responseTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="py-8 text-center text-slate-400">
|
||||||
|
{t('instance.description')}
|
||||||
|
{t('instance.configFile')}
|
||||||
|
{t('instance.file')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.instances.length === 0}
|
||||||
|
<div class="py-8 text-center text-slate-500">{t('instance.noInstances')}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Additional styles if needed */
|
||||||
|
</style>
|
||||||
151
static/llms.txt
Normal file
151
static/llms.txt
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Cactoide
|
||||||
|
|
||||||
|
> A federated mobile-first event RSVP platform built with SvelteKit. Cactoide is an open-source alternative to big tech event platforms like Meetup.com, Eventbrite, and Luma. It allows users to create events, share unique URLs, and collect RSVPs without any registration required. Features include built-in federation for decentralized event discovery across multiple instances, iCal integration, smart capacity limits, and a no-signup approach. The platform uses PostgreSQL with Drizzle ORM, implements federation via configurable instance lists, and supports multiple languages through a simple i18n system.
|
||||||
|
|
||||||
|
Cactoide is an open-source event management platform licensed under AGPL-3.0, designed as a privacy-focused alternative to centralized event platforms. Unlike Meetup.com, Eventbrite, Luma, and other big tech solutions, Cactoide requires no user accounts, collects minimal data, and operates on a federated model that gives users control over their events and data. Events can be public, private, or invite-only. The platform supports both limited and unlimited RSVP capacity.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The project is built with:
|
||||||
|
- **Frontend**: SvelteKit 5 with TypeScript, Tailwind CSS
|
||||||
|
- **Backend**: SvelteKit server routes and API endpoints
|
||||||
|
- **Database**: PostgreSQL with Drizzle ORM
|
||||||
|
- **Deployment**: Docker and Docker Compose support
|
||||||
|
|
||||||
|
Key architectural decisions:
|
||||||
|
- File-based routing (SvelteKit conventions)
|
||||||
|
- Server-side rendering for all pages
|
||||||
|
- Cookie-based user identification (no authentication system)
|
||||||
|
- Federation via HTTP API endpoints between instances
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- [Health Check](/api/healthz): Returns instance health status and response time in milliseconds
|
||||||
|
- [Federation Events](/api/federation/events): Returns all public events from the instance (requires FEDERATION_INSTANCE env variable)
|
||||||
|
- [Federation Info](/api/federation/info): Returns instance name and public events count (requires FEDERATION_INSTANCE env variable)
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The database uses three main tables:
|
||||||
|
- **events**: Stores event information (id, name, date, time, location, type, visibility, attendee_limit, user_id)
|
||||||
|
- **rsvps**: Stores RSVP responses (id, event_id, name, user_id, created_at)
|
||||||
|
- **invite_tokens**: Stores invite tokens for invite-only events (id, event_id, token, expires_at)
|
||||||
|
|
||||||
|
Event visibility can be: public, private, or invite-only. Event types can be: limited or unlimited.
|
||||||
|
|
||||||
|
## Federation
|
||||||
|
|
||||||
|
Federation allows multiple Cactoide instances to share and discover public events. Configuration is managed through `federation.config.js` which contains:
|
||||||
|
- Instance name (display name for the instance)
|
||||||
|
- Instance list (array of federated instance URLs to discover events from)
|
||||||
|
|
||||||
|
To enable federation on an instance:
|
||||||
|
1. Set `FEDERATION_INSTANCE=true` environment variable
|
||||||
|
2. Configure instance name in `federation.config.js`
|
||||||
|
3. Add other instance URLs to the instances array to discover their events
|
||||||
|
|
||||||
|
Federated instances are displayed at `/instance` with health status, response times, and event counts.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
- `/` - Landing page (can be disabled with PUBLIC_LANDING_INFO=false)
|
||||||
|
- `/create` - Event creation form
|
||||||
|
- `/discover` - Public events discovery page with search and filters
|
||||||
|
- `/event/[id]` - Individual event page with RSVP functionality
|
||||||
|
- `/instance` - Federation instances status page
|
||||||
|
|
||||||
|
## Code Structure
|
||||||
|
|
||||||
|
### src/routes/
|
||||||
|
SvelteKit file-based routing system. Each folder represents a route, with `+page.svelte` for UI and `+page.server.ts` for server-side data loading.
|
||||||
|
|
||||||
|
- [src/routes/](https://github.com/polaroi8d/cactoide/tree/main/src/routes): Main routing directory
|
||||||
|
- `+layout.svelte` / `+layout.server.ts`: Root layout component and server-side data loading for all pages
|
||||||
|
- `+page.svelte` / `+page.server.ts`: Landing/home page
|
||||||
|
- `+error.svelte`: Global error page component
|
||||||
|
- [src/routes/create/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/create): Event creation form page
|
||||||
|
- [src/routes/discover/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/discover): Public events discovery page with search and filtering
|
||||||
|
- [src/routes/event/[id]/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/event/[id]): Dynamic route for individual event pages
|
||||||
|
- `edit/`: Event editing functionality
|
||||||
|
- `invite/[token]/`: Invite-only event access via token
|
||||||
|
- [src/routes/instance/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/instance): Federation instances status monitoring page
|
||||||
|
- [src/routes/api/](https://github.com/polaroi8d/cactoide/tree/main/src/routes/api): API endpoints
|
||||||
|
- `healthz/+server.ts`: Health check endpoint with response time
|
||||||
|
- `federation/events/+server.ts`: Returns public events for federation (requires FEDERATION_INSTANCE)
|
||||||
|
- `federation/info/+server.ts`: Returns instance name and public events count (requires FEDERATION_INSTANCE)
|
||||||
|
|
||||||
|
### src/lib/
|
||||||
|
Shared library code accessible via `$lib` alias throughout the application.
|
||||||
|
|
||||||
|
- [src/lib/](https://github.com/polaroi8d/cactoide/tree/main/src/lib): Core library directory
|
||||||
|
- [src/lib/components/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/components): Reusable Svelte components
|
||||||
|
- `Navbar.svelte`: Main navigation bar component
|
||||||
|
- `CalendarModal.svelte`: Calendar integration modal for iCal downloads
|
||||||
|
- [src/lib/database/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/database): Database layer
|
||||||
|
- `schema.ts`: Drizzle ORM schema definitions (events, rsvps, invite_tokens tables)
|
||||||
|
- `db.ts`: Database connection and Drizzle instance setup
|
||||||
|
- `healthCheck.ts`: Database health check utilities
|
||||||
|
- [src/lib/i18n/](https://github.com/polaroi8d/cactoide/tree/main/src/lib/i18n): Internationalization
|
||||||
|
- `i18n.ts`: i18n initialization and translation function
|
||||||
|
- `messages.json`: Default English translations
|
||||||
|
- `it.json`: Italian translation file (example of additional language)
|
||||||
|
- `types.ts`: TypeScript type definitions (Event, RSVP, EventType, EventVisibility, LocationType, etc.)
|
||||||
|
- `dateHelpers.ts`: Date and time formatting utilities, time range filtering for events
|
||||||
|
- `calendarHelpers.ts`: iCal file generation and calendar service link creation (Google Calendar, Outlook, etc.)
|
||||||
|
- `fetchFederatedEvents.ts`: Federation logic for fetching events from other Cactoide instances
|
||||||
|
- `inviteTokenHelpers.ts`: Invite token generation and expiration calculation utilities
|
||||||
|
- `generateUserId.ts`: User ID generation for cookie-based user identification
|
||||||
|
- `logger.ts`: Pino-based logging configuration
|
||||||
|
- `index.ts`: Library entry point (currently placeholder)
|
||||||
|
|
||||||
|
### src/
|
||||||
|
Root source directory containing application configuration and entry points.
|
||||||
|
|
||||||
|
- `app.html`: HTML template for the application
|
||||||
|
- `app.css`: Global CSS styles and Tailwind imports
|
||||||
|
- `app.d.ts`: TypeScript type declarations
|
||||||
|
- `hooks.server.ts`: SvelteKit server hooks for request handling, user ID cookie management, and error handling
|
||||||
|
|
||||||
|
### Root Level Directories
|
||||||
|
|
||||||
|
- [database/](https://github.com/polaroi8d/cactoide/tree/main/database): Database initialization and migration files
|
||||||
|
- `init.sql`: Database schema initialization script (creates tables, enums, indexes)
|
||||||
|
- `seed.sql`: Sample data for development and testing
|
||||||
|
- [database/migrations/](https://github.com/polaroi8d/cactoide/tree/main/database/migrations): SQL migration files for schema changes
|
||||||
|
- [static/](https://github.com/polaroi8d/cactoide/tree/main/static): Static assets served directly by the web server
|
||||||
|
- `favicon.ico`: Site favicon
|
||||||
|
- `robots.txt`: Search engine crawler directives
|
||||||
|
- `llms.txt`: This file - LLM-friendly project documentation
|
||||||
|
- [scripts/](https://github.com/polaroi8d/cactoide/tree/main/scripts): Utility scripts
|
||||||
|
- `i18n-check.sh`: Translation file validation script
|
||||||
|
- [docs/](https://github.com/polaroi8d/cactoide/tree/main/docs): Documentation assets
|
||||||
|
- `federation_example.png`: Screenshot example for federation documentation
|
||||||
|
|
||||||
|
### Configuration Files
|
||||||
|
|
||||||
|
- `federation.config.js`: Federation configuration (instance name and list of federated instance URLs)
|
||||||
|
- `package.json`: Node.js dependencies and scripts
|
||||||
|
- `svelte.config.js`: SvelteKit configuration (adapter, preprocessors)
|
||||||
|
- `vite.config.ts`: Vite build tool configuration
|
||||||
|
- `tailwind.config.js`: Tailwind CSS configuration
|
||||||
|
- `tsconfig.json`: TypeScript compiler configuration
|
||||||
|
- `eslint.config.js`: ESLint linting rules
|
||||||
|
- `docker-compose.yml`: Docker Compose setup for local development with PostgreSQL
|
||||||
|
- `Dockerfile`: Production Docker image configuration
|
||||||
|
- `Makefile`: Development command shortcuts (db-only, i18n validation, etc.)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Key environment variables:
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `FEDERATION_INSTANCE`: Set to `true` to enable federation API endpoints
|
||||||
|
- `PUBLIC_LANDING_INFO`: Set to `false` to disable landing page and redirect to `/discover`
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [docker-compose.yml](https://github.com/polaroi8d/cactoide/blob/main/docker-compose.yml): Docker Compose configuration for local development
|
||||||
|
- [Dockerfile](https://github.com/polaroi8d/cactoide/blob/main/Dockerfile): Production Docker image configuration
|
||||||
|
- [database/init.sql](https://github.com/polaroi8d/cactoide/blob/main/database/init.sql): Database initialization SQL
|
||||||
|
- [database/migrations/](https://github.com/polaroi8d/cactoide/tree/main/database/migrations): Database migration files
|
||||||
|
- [Makefile](https://github.com/polaroi8d/cactoide/blob/main/Makefile): Development commands and shortcuts
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler"
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["node"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user