diff --git a/README.md b/README.md new file mode 100644 index 0000000..d24ecee --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# The Collective Hub + +> A reusable SvelteKit website template for launching branded landing pages for online theater hosts, watch-party communities, and similar groups. One codebase, multiple deployed websites, one shared database, one CDN. + +## Features + +- Public homepage for a theater/community host with event and schedule display +- Discord OAuth login for site owners and admins +- Admin panel for customizing branding (name, logo, colors, tagline) +- Homepage content editor (intro text, hero, buttons, links) +- Multi-site support from a single codebase using `SITE_SLUG` environment variable +- Shared PostgreSQL database with data scoped by `siteId` — logically multi-tenant +- Shared CDN/object storage bucket with per-site path namespacing +- Full image upload flow with automatic WebP conversion and optimization +- Asset library for browsing and managing uploaded files +- Super admin cross-site access via `SUPER_ADMIN_DISCORD_IDS` + +## Tech Stack + +| Layer | Choice | Notes | +|-------|--------|-------| +| Framework | SvelteKit | File-based routing, SSR, API routes | +| Language | TypeScript | Strict mode | +| Database | PostgreSQL 16 | Single shared instance for all sites | +| ORM | Drizzle ORM | Type-safe, SQL-first | +| Auth | Better Auth | Discord OAuth provider | +| CDN / Storage | Bunny CDN | Single bucket, site-scoped paths, auto WebP conversion | +| Image Processing | Sharp | Server-side resize and format conversion | +| Containerization | Docker | Multi-stage build, Node 22 Alpine | +| Deployment | Coolify | Multiple deployments from one Git repo | + +## Getting Started + +### Prerequisites + +- Node.js 22+ +- Docker (for local PostgreSQL) + +### Setup + +```bash +# 1. Clone and install +git clone +cd collective-terminal +npm install + +# 2. Start PostgreSQL +docker compose up -d + +# 3. Create your .env file +``` + +Create a `.env` file at the project root with these values for local development: + +```env +DATABASE_URL=postgresql://hub_dev:hub_dev_password@localhost:5432/collective_hub +SITE_SLUG=local-dev +``` + +```bash +# 4. Create database tables +npm run db:push +``` + +> **Alternative:** Generate and run formal migrations instead of `db:push`: +> ```bash +> npm run db:generate +> npm run db:migrate +> ``` + +```bash +# 5. Seed the default "local-dev" site +npm run db:seed + +# 6. Start the dev server +npm run dev +``` + +The app opens at [http://localhost:5173](http://localhost:5173). + +> **Note:** Discord login will not work locally without a configured Discord OAuth application with `http://localhost:5173` in its redirect URL list. The site resolver and public homepage function without Discord auth. + +## Environment Variables + +### Required + +| Variable | Example | Description | +|----------|---------|-------------| +| `SITE_SLUG` | `bad-movies-theater` | Identifies which site this deployment serves. Must match a `slug` in the `sites` table. | +| `PUBLIC_SITE_URL` | `https://badmovies.example.com` | Public URL of this deployment. Used for auth callbacks and canonical URLs. | +| `DATABASE_URL` | `postgresql://user:pass@host:5432/collective_hub` | PostgreSQL connection string. Same value across all deployments. | +| `BETTER_AUTH_SECRET` | *(generated)* | Secret key for session signing. Must be identical across all deployments. | +| `BETTER_AUTH_URL` | `https://badmovies.example.com` | Base URL for auth callbacks. Must match `PUBLIC_SITE_URL`. | +| `DISCORD_CLIENT_ID` | `123456789012345678` | Discord OAuth application client ID. Shared across all deployments. | +| `DISCORD_CLIENT_SECRET` | `abc123...` | Discord OAuth application client secret. Shared across all deployments. | +| `OWNER_DISCORD_ID` | `123456789012345678` | Discord user ID of the site owner. Bootstraps ownership on first login. Per-site. | + +### CDN (Required for asset uploads) + +| Variable | Example | Description | +|----------|---------|-------------| +| `CDN_BASE_URL` | `https://cdn.example.com` | Base URL for constructing public CDN URLs. | +| `CDN_STORAGE_ENDPOINT` | `https://ny.storage.bunnycdn.com` | Storage API endpoint. | +| `CDN_ACCESS_KEY` | `abc123...` | Storage access key (BunnyCDN password or S3 access key). | +| `CDN_BUCKET` | `collective-hub` | Bucket or storage zone name. | + +### Optional + +| Variable | Example | Description | +|----------|---------|-------------| +| `SUPER_ADMIN_DISCORD_IDS` | `123456789,987654321` | Comma-separated Discord user IDs with cross-site super admin access. | +| `RUN_MIGRATIONS` | `true` | Whether this deployment runs database migrations on startup. Only **one** deployment should have `true`. | +| `NODE_ENV` | `production` | Node environment (`development` or `production`). | +| `LOG_LEVEL` | `info` | Logging verbosity (`debug`, `info`, `warn`, `error`). | + +### Shared vs Per-Site + +**Shared** (same across all deployments): `DATABASE_URL`, `BETTER_AUTH_SECRET`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `SUPER_ADMIN_DISCORD_IDS`, `CDN_*` + +**Per-site** (unique per deployment): `SITE_SLUG`, `PUBLIC_SITE_URL`, `BETTER_AUTH_URL`, `OWNER_DISCORD_ID`, `RUN_MIGRATIONS` + +## npm Scripts + +| Script | Command | Description | +|--------|---------|-------------| +| `dev` | `vite dev` | Start the Vite dev server with HMR. | +| `build` | `vite build` | Build for production (output to `build/`). | +| `preview` | `vite preview` | Preview the production build locally. | +| `db:generate` | `drizzle-kit generate` | Generate SQL migration files from Drizzle schema changes. | +| `db:migrate` | `drizzle-kit migrate` | Apply pending SQL migrations to the database. | +| `db:push` | `drizzle-kit push` | Push schema changes directly to the database (no migration files). Convenient for early development. | +| `db:studio` | `drizzle-kit studio` | Open Drizzle Studio, a GUI for browsing and editing data. | +| `db:seed` | `tsx src/lib/server/db/seed.ts` | Insert the default `local-dev` site and its settings row. Idempotent — safe to run multiple times. | +| `check` | `svelte-kit sync && svelte-check` | Type-check the project with `svelte-check`. | +| `lint` | `eslint .` | Run ESLint across the project. | +| `format` | `prettier --write .` | Format all source files with Prettier. | + +## Project Structure + +``` +. +├── src/ +│ ├── app.d.ts # App types, locals augmentation +│ ├── app.html # HTML shell +│ ├── hooks.server.ts # Site resolver, auth handling +│ ├── lib/ +│ │ ├── server/ +│ │ │ ├── auth.ts # Better Auth configuration +│ │ │ ├── cdn.ts # CDN URL helpers +│ │ │ ├── site-resolver.ts # Site loading by SITE_SLUG +│ │ │ └── db/ +│ │ │ ├── index.ts # Drizzle + Postgres connection +│ │ │ ├── migrate.ts # Automated migration runner +│ │ │ ├── schema.ts # All table definitions +│ │ │ └── seed.ts # Local dev seed data +│ │ └── shared/ +│ │ └── types.ts # Shared TypeScript types +│ └── routes/ +│ ├── +layout.server.ts # Root layout, loads site context +│ ├── +layout.svelte +│ ├── +page.server.ts # Public homepage data +│ ├── +page.svelte # Public homepage +│ ├── login/ +│ │ └── +page.svelte # Discord OAuth login page +│ ├── admin/ +│ │ ├── +layout.server.ts # Admin auth guard +│ │ ├── +layout.svelte # Admin shell / navigation +│ │ ├── +page.svelte # Admin dashboard +│ │ ├── branding/ # Logo, colors, theme editor +│ │ ├── settings/ # Site settings editor +│ │ └── assets/ # Asset library (upload, browse) +│ └── api/ +│ └── assets/ # Asset upload API endpoint +├── drizzle/ # Drizzle migration files +├── scripts/ # Utility scripts +├── docs/ # Project documentation +├── docker-compose.yml # Local PostgreSQL +├── Dockerfile # Multi-stage production build +├── drizzle.config.ts # Drizzle Kit configuration +├── svelte.config.js # SvelteKit configuration +├── tsconfig.json +├── vite.config.ts +└── package.json +``` + +## Deployment + +The project is deployed via **Coolify** with Docker. Each community site is a separate Coolify deployment pointing to the same Git repository and branch. Deployments are differentiated solely by environment variables — specifically `SITE_SLUG`. + +``` +Git Repo (main branch) + │ + ├── Coolify Deployment "bad-movies-theater" + │ └── Env: SITE_SLUG=bad-movies-theater + │ + ├── Coolify Deployment "garbage-day" + │ └── Env: SITE_SLUG=garbage-day + │ + └── Coolify Deployment "future-site" + └── Env: SITE_SLUG=future-site +``` + +Key deployment rules: + +- All deployments share one PostgreSQL database and one CDN bucket. +- Exactly **one** deployment must have `RUN_MIGRATIONS=true`. All others set `RUN_MIGRATIONS=false`. +- The migration-runner deployment should be deployed first when schema changes are included in a release. +- Sensitive values (secrets, keys, connection strings) are stored in Coolify's environment variable management — never committed to the repository. +- The Dockerfile uses a multi-stage build (Node 22 Alpine) producing a small production image that runs [`build/index.js`](build/index.js). + +## License + +TBD diff --git a/drizzle/0001_huge_arachne.sql b/drizzle/0001_huge_arachne.sql new file mode 100644 index 0000000..ed36c7a --- /dev/null +++ b/drizzle/0001_huge_arachne.sql @@ -0,0 +1,28 @@ +CREATE TABLE "nav_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "label" text NOT NULL, + "url" text NOT NULL, + "position" text DEFAULT 'header' NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "is_external" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "social_links" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "platform" text NOT NULL, + "label" text, + "url" text NOT NULL, + "icon" text, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "nav_links" ADD CONSTRAINT "nav_links_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "social_links" ADD CONSTRAINT "social_links_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "nav_links_site_position_order_idx" ON "nav_links" USING btree ("site_id","position","sort_order");--> statement-breakpoint +CREATE INDEX "social_links_site_order_idx" ON "social_links" USING btree ("site_id","sort_order"); \ No newline at end of file diff --git a/drizzle/0002_brief_proemial_gods.sql b/drizzle/0002_brief_proemial_gods.sql new file mode 100644 index 0000000..460f9fc --- /dev/null +++ b/drizzle/0002_brief_proemial_gods.sql @@ -0,0 +1,21 @@ +CREATE TABLE "events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "site_id" uuid NOT NULL, + "title" text NOT NULL, + "description" text, + "event_type" text DEFAULT 'screening' NOT NULL, + "start_time" timestamp with time zone NOT NULL, + "end_time" timestamp with time zone, + "timezone" text DEFAULT 'America/New_York' NOT NULL, + "location" text, + "external_link" text, + "image_cdn_key" text, + "is_published" boolean DEFAULT false NOT NULL, + "is_recurring" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "events" ADD CONSTRAINT "events_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "events_site_start_idx" ON "events" USING btree ("site_id","start_time");--> statement-breakpoint +CREATE INDEX "events_site_published_idx" ON "events" USING btree ("site_id","is_published"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..aa97aba --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,773 @@ +{ + "id": "87f348f9-8e23-4ffe-9d54-f4516dd4192f", + "prevId": "a5b6b795-1a2c-4068-ba92-e80ff48fcb3a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cdn_key": { + "name": "cdn_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_site_id_idx": { + "name": "assets_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_cdn_key_idx": { + "name": "assets_cdn_key_idx", + "columns": [ + { + "expression": "cdn_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_site_id_sites_id_fk": { + "name": "assets_site_id_sites_id_fk", + "tableFrom": "assets", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_uploaded_by_user_id_users_id_fk": { + "name": "assets_uploaded_by_user_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_site_user_idx": { + "name": "memberships_site_user_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_site_id_idx": { + "name": "memberships_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_user_id_idx": { + "name": "memberships_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_site_id_sites_id_fk": { + "name": "memberships_site_id_sites_id_fk", + "tableFrom": "memberships", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nav_links": { + "name": "nav_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'header'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_external": { + "name": "is_external", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nav_links_site_position_order_idx": { + "name": "nav_links_site_position_order_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "nav_links_site_id_sites_id_fk": { + "name": "nav_links_site_id_sites_id_fk", + "tableFrom": "nav_links", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "site_settings_site_id_idx": { + "name": "site_settings_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_settings_site_id_sites_id_fk": { + "name": "site_settings_site_id_sites_id_fk", + "tableFrom": "site_settings", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "site_settings_site_id_unique": { + "name": "site_settings_site_id_unique", + "nullsNotDistinct": false, + "columns": [ + "site_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_slug_idx": { + "name": "sites_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sites_slug_unique": { + "name": "sites_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_links": { + "name": "social_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_links_site_order_idx": { + "name": "social_links_site_order_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_links_site_id_sites_id_fk": { + "name": "social_links_site_id_sites_id_fk", + "tableFrom": "social_links", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_avatar": { + "name": "discord_avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_discord_id_idx": { + "name": "users_discord_id_idx", + "columns": [ + { + "expression": "discord_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "owner", + "admin", + "editor" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fe17f0a --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,940 @@ +{ + "id": "4af3e5bc-672d-47b4-a475-ea4238049c48", + "prevId": "87f348f9-8e23-4ffe-9d54-f4516dd4192f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cdn_key": { + "name": "cdn_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_site_id_idx": { + "name": "assets_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_cdn_key_idx": { + "name": "assets_cdn_key_idx", + "columns": [ + { + "expression": "cdn_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_site_id_sites_id_fk": { + "name": "assets_site_id_sites_id_fk", + "tableFrom": "assets", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_uploaded_by_user_id_users_id_fk": { + "name": "assets_uploaded_by_user_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'screening'" + }, + "start_time": { + "name": "start_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'America/New_York'" + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_link": { + "name": "external_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_cdn_key": { + "name": "image_cdn_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recurring": { + "name": "is_recurring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_site_start_idx": { + "name": "events_site_start_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_time", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "events_site_published_idx": { + "name": "events_site_published_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_published", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "events_site_id_sites_id_fk": { + "name": "events_site_id_sites_id_fk", + "tableFrom": "events", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_site_user_idx": { + "name": "memberships_site_user_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_site_id_idx": { + "name": "memberships_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_user_id_idx": { + "name": "memberships_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_site_id_sites_id_fk": { + "name": "memberships_site_id_sites_id_fk", + "tableFrom": "memberships", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nav_links": { + "name": "nav_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'header'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_external": { + "name": "is_external", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "nav_links_site_position_order_idx": { + "name": "nav_links_site_position_order_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "nav_links_site_id_sites_id_fk": { + "name": "nav_links_site_id_sites_id_fk", + "tableFrom": "nav_links", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "site_settings_site_id_idx": { + "name": "site_settings_site_id_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "site_settings_site_id_sites_id_fk": { + "name": "site_settings_site_id_sites_id_fk", + "tableFrom": "site_settings", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "site_settings_site_id_unique": { + "name": "site_settings_site_id_unique", + "nullsNotDistinct": false, + "columns": [ + "site_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sites_slug_idx": { + "name": "sites_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sites_slug_unique": { + "name": "sites_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.social_links": { + "name": "social_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "social_links_site_order_idx": { + "name": "social_links_site_order_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "social_links_site_id_sites_id_fk": { + "name": "social_links_site_id_sites_id_fk", + "tableFrom": "social_links", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_id": { + "name": "discord_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_avatar": { + "name": "discord_avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_discord_id_idx": { + "name": "users_discord_id_idx", + "columns": [ + { + "expression": "discord_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "owner", + "admin", + "editor" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 19dece1..7d9dad7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1780717416486, "tag": "0000_moaning_the_initiative", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1780726060837, + "tag": "0001_huge_arachne", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1780726716892, + "tag": "0002_brief_proemial_gods", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 548a1c5..74c2d7b 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -135,6 +135,88 @@ export const assets = pgTable( ] ); +// ─── Nav Links ─────────────────────────────────────────────────────────────── + +export const navLinks = pgTable( + 'nav_links', + { + id: uuid('id').defaultRandom().primaryKey(), + siteId: uuid('site_id') + .notNull() + .references(() => sites.id, { onDelete: 'cascade' }), + label: text('label').notNull(), + url: text('url').notNull(), + position: text('position').notNull().default('header'), + sortOrder: integer('sort_order').notNull().default(0), + isExternal: boolean('is_external').notNull().default(true), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()) + }, + (table) => [ + index('nav_links_site_position_order_idx').on(table.siteId, table.position, table.sortOrder) + ] +); + +// ─── Social Links ──────────────────────────────────────────────────────────── + +export const socialLinks = pgTable( + 'social_links', + { + id: uuid('id').defaultRandom().primaryKey(), + siteId: uuid('site_id') + .notNull() + .references(() => sites.id, { onDelete: 'cascade' }), + platform: text('platform').notNull(), + label: text('label'), + url: text('url').notNull(), + icon: text('icon'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()) + }, + (table) => [ + index('social_links_site_order_idx').on(table.siteId, table.sortOrder) + ] +); + +// ─── Events ────────────────────────────────────────────────────────────────── + +export const events = pgTable( + 'events', + { + id: uuid('id').defaultRandom().primaryKey(), + siteId: uuid('site_id') + .notNull() + .references(() => sites.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description'), + eventType: text('event_type').notNull().default('screening'), + startTime: timestamp('start_time', { withTimezone: true }).notNull(), + endTime: timestamp('end_time', { withTimezone: true }), + timezone: text('timezone').notNull().default('America/New_York'), + location: text('location'), + externalLink: text('external_link'), + imageCdnKey: text('image_cdn_key'), + isPublished: boolean('is_published').notNull().default(false), + isRecurring: boolean('is_recurring').notNull().default(false), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()) + }, + (table) => [ + index('events_site_start_idx').on(table.siteId, table.startTime), + index('events_site_published_idx').on(table.siteId, table.isPublished) + ] +); + // ─── Type Exports ──────────────────────────────────────────────────────────── export type Site = typeof sites.$inferSelect; @@ -151,3 +233,12 @@ export type NewSiteSetting = typeof siteSettings.$inferInsert; export type Asset = typeof assets.$inferSelect; export type NewAsset = typeof assets.$inferInsert; + +export type NavLink = typeof navLinks.$inferSelect; +export type NewNavLink = typeof navLinks.$inferInsert; + +export type SocialLink = typeof socialLinks.$inferSelect; +export type NewSocialLink = typeof socialLinks.$inferInsert; + +export type Event = typeof events.$inferSelect; +export type NewEvent = typeof events.$inferInsert; diff --git a/src/lib/shared/timezone.ts b/src/lib/shared/timezone.ts new file mode 100644 index 0000000..41ac5d8 --- /dev/null +++ b/src/lib/shared/timezone.ts @@ -0,0 +1,50 @@ +/** + * Client-safe timezone formatting utilities. + * + * All event times are stored as timestamptz (UTC) in the database. + * These functions use the browser's `Intl.DateTimeFormat` to display + * times in the visitor's local timezone. + */ + +/** + * Format a UTC date string for display in the visitor's local timezone. + * Returns a formatted string like "Sat, Jun 14, 2025 at 8:00 PM". + */ +export function formatEventTime(utcIsoString: string): string { + const date = new Date(utcIsoString); + return date.toLocaleDateString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); +} + +/** + * Format just the date portion (no time). + * Returns a formatted string like "Sat, Jun 14". + */ +export function formatEventDate(utcIsoString: string): string { + const date = new Date(utcIsoString); + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric' + }); +} + +/** + * Format just the time portion. + * Returns a formatted string like "8:00 PM EST". + */ +export function formatEventTimeOnly(utcIsoString: string): string { + const date = new Date(utcIsoString); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 2d3c7bf..df34e6c 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,12 +1,16 @@ import type { PageServerLoad } from './$types'; import { getCdnUrl } from '$lib/server/cdn'; +import { db } from '$lib/server/db'; +import { navLinks, socialLinks, events } from '$lib/server/db/schema'; +import { eq, asc, and, gte } from 'drizzle-orm'; /** * Homepage server load — flattens site settings into simple props. * - * All data originates from the root layout server (+layout.server.ts). - * This loader calls event.parent() to access that cached data so we - * make zero additional database queries here. + * Most data originates from the root layout server (+layout.server.ts). + * This loader calls event.parent() to access that cached data for + * branding/theme/homepage info. Nav links and social links are queried + * directly from the database since they aren't in layout data. * * Fallback chain for each prop ensures the page always has sensible * values, even when settings have never been configured by the owner. @@ -40,6 +44,63 @@ export const load: PageServerLoad = async (event) => { const backgroundUrl = branding?.backgroundCdnKey ? getCdnUrl(branding.backgroundCdnKey) : null; const faviconUrl = branding?.faviconCdnKey ? getCdnUrl(branding.faviconCdnKey) : null; + // Query nav links and social links for the current site + let navLinkRows: typeof navLinks.$inferSelect[] = []; + let socialLinkRows: typeof socialLinks.$inferSelect[] = []; + + if (site) { + navLinkRows = await db + .select() + .from(navLinks) + .where(eq(navLinks.siteId, site.id)) + .orderBy(asc(navLinks.position), asc(navLinks.sortOrder)); + + socialLinkRows = await db + .select() + .from(socialLinks) + .where(eq(socialLinks.siteId, site.id)) + .orderBy(asc(socialLinks.sortOrder)); + } + + // ─── Events queries ────────────────────────────────────────────────── + + let nextEvent: typeof events.$inferSelect | null = null; + let upcomingEvents: typeof events.$inferSelect[] = []; + + if (site) { + const now = new Date(); + + // Next upcoming published event + const [next] = await db + .select() + .from(events) + .where( + and( + eq(events.siteId, site.id), + eq(events.isPublished, true), + gte(events.startTime, now) + ) + ) + .orderBy(asc(events.startTime)) + .limit(1); + + nextEvent = next ?? null; + + // Upcoming published events (next 6) + upcomingEvents = await db + .select() + .from(events) + .where( + and( + eq(events.siteId, site.id), + eq(events.isPublished, true), + gte(events.startTime, now) + ) + ) + .orderBy(asc(events.startTime)) + .limit(6); + } + return { site, heroTitle, @@ -53,6 +114,10 @@ export const load: PageServerLoad = async (event) => { membership, logoUrl, backgroundUrl, - faviconUrl + faviconUrl, + navLinks: navLinkRows, + socialLinks: socialLinkRows, + nextEvent, + upcomingEvents }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1ba21a8..40fc0ad 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,8 +1,12 @@ @@ -27,7 +61,72 @@

Run npm run db:seed to create the default "local-dev" site.

{:else} + + + + + + + {#if navOpen} + + + + {/if} + + +
+ + {#if data.aboutText}
@@ -74,7 +175,138 @@
{/if} + + + + {#if data.showNextEvent && data.nextEvent} +
+
+

Next Event

+ +
+
+

{data.nextEvent.title}

+ +
+ + 📅 {formatEventTime(data.nextEvent.startTime)} + + + {#if data.nextEvent.endTime} + + → {formatEventTimeOnly(data.nextEvent.endTime)} + + {/if} + + + {eventTypeLabel(data.nextEvent.eventType)} + +
+ + {#if data.nextEvent.description} +

+ {truncate(data.nextEvent.description, 150)} +

+ {/if} + + {#if data.nextEvent.location} +

+ 📍 {data.nextEvent.location} +

+ {/if} +
+ + {#if data.nextEvent.externalLink} + + {/if} +
+
+
+ {:else if data.showNextEvent && !data.nextEvent} +
+
+

Next Event

+

No upcoming events — check back soon!

+
+
+ {/if} + + + + + {#if data.showSchedule && data.upcomingEvents && data.upcomingEvents.length > 0} + {@const displayEvents = data.nextEvent + ? data.upcomingEvents.filter((e) => e.id !== data.nextEvent!.id).slice(0, 5) + : data.upcomingEvents.slice(0, 5)} + + {#if displayEvents.length > 0} +
+
+

Upcoming Events

+ +
+ {#each displayEvents as evt} +
+ + {formatEventDate(evt.startTime)} + + {evt.title} + + {eventTypeLabel(evt.eventType)} + + {#if evt.startTime} + + {formatEventTimeOnly(evt.startTime)} + + {/if} +
+ {/each} +
+
+
+ {/if} + {/if} + + + + + {#if data.socialLinks && data.socialLinks.length > 0} + + {/if} + + +