feat(db): add nav_links, social_links, and events tables

- Add nav_links table with siteId, label, url, position, sortOrder, isExternal
- Add social_links table with siteId, platform, label, url, icon, sortOrder
- Add events table with siteId, title, description, eventType, startTime, endTime, timezone, location, externalLink, imageCdnKey, isPublished, isRecurring
- Include corresponding Drizzle migration entries
This commit is contained in:
2026-06-06 02:25:49 -04:00
parent f4245a996a
commit 7588ddce1f
17 changed files with 6163 additions and 7 deletions
+213
View File
@@ -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 <repo-url>
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
+28
View File
@@ -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");
+21
View File
@@ -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");
+773
View File
@@ -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": {}
}
}
+940
View File
@@ -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": {}
}
}
+14
View File
@@ -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
}
]
}
+91
View File
@@ -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;
+50
View File
@@ -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'
});
}
+69 -4
View File
@@ -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
};
};
+648
View File
@@ -1,8 +1,12 @@
<script lang="ts">
import type { PageData } from './$types';
import { formatEventTime, formatEventDate, formatEventTimeOnly } from '$lib/shared/timezone';
let { data }: { data: PageData } = $props();
/** Whether the mobile nav menu is open */
let navOpen = $state(false);
/**
* Determine if a link is external (starts with http:// or https://).
* Internal links (starting with `/`) navigate normally.
@@ -10,6 +14,36 @@
function isExternal(href: string): boolean {
return /^https?:\/\//.test(href);
}
/** Close mobile nav when a link is clicked */
function closeNav() {
navOpen = false;
}
/**
* Truncate text to a maximum length, appending "..." if truncated.
*/
function truncate(text: string | null | undefined, maxLen: number): string {
if (!text) return '';
if (text.length <= maxLen) return text;
return text.slice(0, maxLen).trimEnd() + '...';
}
/** Get a human-readable label for event type */
function eventTypeLabel(type: string): string {
const map: Record<string, string> = {
screening: 'Screening',
watch_party: 'Watch Party',
meetup: 'Meetup',
other: 'Other'
};
return map[type] ?? type;
}
/** Get CSS class for event type badge */
function eventTypeBadgeClass(type: string): string {
return `event-type-badge event-type-badge--${type}`;
}
</script>
<svelte:head>
@@ -27,7 +61,72 @@
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
</main>
{:else}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- NAV BAR -->
<!-- ═══════════════════════════════════════════════════════════ -->
<nav class="navbar">
<div class="navbar-inner">
<!-- Logo / Site Name -->
<a href="/" class="navbar-brand">
{#if data.logoUrl}
<img
src={data.logoUrl}
alt={data.site.name}
class="navbar-logo"
width="32"
height="32"
/>
{/if}
<span class="navbar-name">{data.site.name}</span>
</a>
<!-- Nav Links (header position only) -->
{#if data.navLinks && data.navLinks.length > 0}
<div class="navbar-links" class:navbar-links--open={navOpen}>
{#each data.navLinks.filter((l) => l.position === 'header') as link}
{@const external = isExternal(link.url)}
<a
href={link.url}
class="navbar-link"
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
onclick={closeNav}
>
{link.label}
{#if external}
<span class="external-icon"></span>
{/if}
</a>
{/each}
</div>
{/if}
<!-- Hamburger (mobile) -->
{#if data.navLinks && data.navLinks.length > 0}
<button
class="navbar-toggle"
onclick={() => (navOpen = !navOpen)}
aria-label="Toggle navigation"
aria-expanded={navOpen}
>
<span class="navbar-toggle-bar"></span>
<span class="navbar-toggle-bar"></span>
<span class="navbar-toggle-bar"></span>
</button>
{/if}
</div>
</nav>
<!-- Mobile nav overlay -->
{#if navOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="nav-overlay" onclick={closeNav}></div>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- HERO SECTION -->
<!-- ═══════════════════════════════════════════════════════════ -->
<section
class="hero"
style={data.backgroundUrl
@@ -64,7 +163,9 @@
</div>
</section>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ABOUT SECTION (only if aboutText is set) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if data.aboutText}
<section class="about">
<div class="about-content">
@@ -74,7 +175,138 @@
</section>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- NEXT EVENT SECTION (only if showNextEvent is true AND nextEvent exists) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if data.showNextEvent && data.nextEvent}
<section class="next-event-section">
<div class="next-event-content">
<h2 class="section-heading">Next Event</h2>
<div class="next-event-card">
<div class="next-event-body">
<h3 class="next-event-title">{data.nextEvent.title}</h3>
<div class="next-event-meta">
<span class="next-event-datetime">
📅 {formatEventTime(data.nextEvent.startTime)}
</span>
{#if data.nextEvent.endTime}
<span class="next-event-datetime next-event-end">
{formatEventTimeOnly(data.nextEvent.endTime)}
</span>
{/if}
<span class={eventTypeBadgeClass(data.nextEvent.eventType)}>
{eventTypeLabel(data.nextEvent.eventType)}
</span>
</div>
{#if data.nextEvent.description}
<p class="next-event-desc">
{truncate(data.nextEvent.description, 150)}
</p>
{/if}
{#if data.nextEvent.location}
<p class="next-event-location">
📍 {data.nextEvent.location}
</p>
{/if}
</div>
{#if data.nextEvent.externalLink}
<div class="next-event-action">
<a
href={data.nextEvent.externalLink}
class="event-details-link"
target="_blank"
rel="noopener noreferrer"
>
Event Details ↗
</a>
</div>
{/if}
</div>
</div>
</section>
{:else if data.showNextEvent && !data.nextEvent}
<section class="next-event-section">
<div class="next-event-content">
<h2 class="section-heading">Next Event</h2>
<p class="no-events-message">No upcoming events — check back soon!</p>
</div>
</section>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- UPCOMING EVENTS SECTION (only if showSchedule is true AND upcomingEvents has items) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#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}
<section class="upcoming-events-section">
<div class="upcoming-events-content">
<h2 class="section-heading">Upcoming Events</h2>
<div class="upcoming-events-list">
{#each displayEvents as evt}
<div class="upcoming-event-row">
<span class="upcoming-event-date">
{formatEventDate(evt.startTime)}
</span>
<span class="upcoming-event-title">{evt.title}</span>
<span class={eventTypeBadgeClass(evt.eventType)}>
{eventTypeLabel(evt.eventType)}
</span>
{#if evt.startTime}
<span class="upcoming-event-time">
{formatEventTimeOnly(evt.startTime)}
</span>
{/if}
</div>
{/each}
</div>
</div>
</section>
{/if}
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- SOCIAL LINKS SECTION (only if socialLinks has items) -->
<!-- ═══════════════════════════════════════════════════════════ -->
{#if data.socialLinks && data.socialLinks.length > 0}
<section class="social-links-section">
<div class="social-links-content">
<h2 class="social-links-heading">Connect With Us</h2>
<div class="social-links-row">
{#each data.socialLinks as link}
{@const external = isExternal(link.url)}
<a
href={link.url}
class="social-link"
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
title={link.label || link.platform}
>
<span class="social-link-icon">{link.platform}</span>
{#if link.label}
<span class="social-link-label">{link.label}</span>
{/if}
</a>
{/each}
</div>
</div>
</section>
{/if}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- FOOTER -->
<!-- ═══════════════════════════════════════════════════════════ -->
<footer class="footer">
<div class="footer-content">
<span class="footer-site-name">{data.site.name}</span>
@@ -87,6 +319,208 @@
{/if}
<style>
/* ═══════════════════════════════════════════════════════════ */
/* Nav Bar */
/* ═══════════════════════════════════════════════════════════ */
.navbar {
position: sticky;
top: 0;
z-index: 50;
background: var(--color-background, #1a1a2e);
border-bottom: 1px solid rgba(176, 176, 176, 0.15);
backdrop-filter: blur(8px);
}
.navbar-inner {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
gap: 1rem;
}
.navbar-brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--color-text, #eaeaea);
font-weight: 700;
font-size: 1.05rem;
flex-shrink: 0;
}
.navbar-brand:hover {
opacity: 0.85;
}
.navbar-logo {
border-radius: 4px;
object-fit: contain;
}
.navbar-links {
display: flex;
align-items: center;
gap: 0.25rem;
}
.navbar-link {
padding: 0.45rem 0.8rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-secondary, #b0b0b0);
text-decoration: none;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.navbar-link:hover {
color: var(--color-text, #eaeaea);
background: rgba(255, 255, 255, 0.06);
}
.external-icon {
font-size: 0.75rem;
margin-left: 0.15rem;
opacity: 0.6;
}
/* Hamburger */
.navbar-toggle {
display: none;
flex-direction: column;
gap: 5px;
background: none;
border: none;
cursor: pointer;
padding: 6px;
}
.navbar-toggle-bar {
display: block;
width: 24px;
height: 2px;
background: var(--color-text, #eaeaea);
border-radius: 1px;
transition: transform 0.2s, opacity 0.2s;
}
/* Mobile nav overlay */
.nav-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 40;
}
/* ═══════════════════════════════════════════════════════════ */
/* Social Links Section */
/* ═══════════════════════════════════════════════════════════ */
.social-links-section {
padding: 3rem 2rem;
display: flex;
justify-content: center;
border-top: 1px solid rgba(176, 176, 176, 0.1);
}
.social-links-content {
max-width: 720px;
width: 100%;
text-align: center;
}
.social-links-heading {
font-size: 1.25rem;
font-weight: 600;
color: var(--color-text, #eaeaea);
margin: 0 0 1.25rem;
}
.social-links-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
}
.social-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text-secondary, #b0b0b0);
text-decoration: none;
border: 1px solid rgba(176, 176, 176, 0.2);
border-radius: 8px;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.social-link:hover {
color: var(--color-accent, #e63946);
border-color: var(--color-accent, #e63946);
background: rgba(255, 255, 255, 0.03);
}
.social-link-icon {
font-size: 0.85rem;
font-weight: 600;
text-transform: capitalize;
}
.social-link-label {
font-size: 0.85rem;
}
/* ── Responsive Nav ────────────────────────────────────────── */
@media (max-width: 768px) {
.navbar-toggle {
display: flex;
}
.navbar-links {
display: none;
position: absolute;
top: 56px;
left: 0;
right: 0;
background: var(--color-background, #1a1a2e);
flex-direction: column;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid rgba(176, 176, 176, 0.15);
z-index: 45;
gap: 0.25rem;
}
.navbar-links--open {
display: flex;
}
.navbar-link {
padding: 0.6rem 0.75rem;
width: 100%;
}
.nav-overlay {
display: block;
}
.navbar-name {
display: none;
}
.social-links-section {
padding: 2rem 1.25rem;
}
}
/* Error State */
.error-state {
display: flex;
@@ -202,6 +636,194 @@
transform: translateY(0);
}
/* Next Event Section */
.next-event-section {
padding: 3rem 2rem;
display: flex;
justify-content: center;
border-top: 1px solid rgba(176, 176, 176, 0.1);
}
.next-event-content {
max-width: 720px;
width: 100%;
}
.section-heading {
font-size: 1.75rem;
font-weight: 700;
color: var(--color-text, #eaeaea);
margin: 0 0 1.5rem;
}
.next-event-card {
background: var(--color-card-background, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(176, 176, 176, 0.2));
border-radius: 12px;
padding: 1.75rem;
transition: border-color 0.2s;
}
.next-event-card:hover {
border-color: var(--color-accent, #e63946);
}
.next-event-body {
margin-bottom: 1rem;
}
.next-event-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--color-text, #eaeaea);
margin: 0 0 0.75rem;
}
.next-event-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.next-event-datetime {
font-size: 0.9rem;
color: var(--color-text-secondary, #b0b0b0);
font-weight: 500;
}
.next-event-end {
opacity: 0.75;
}
.event-type-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.2rem 0.55rem;
border-radius: 4px;
}
.event-type-badge--screening {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.event-type-badge--watch_party {
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
}
.event-type-badge--meetup {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
.event-type-badge--other {
background: rgba(156, 163, 175, 0.2);
color: #d1d5db;
}
.next-event-desc {
font-size: 0.95rem;
color: var(--color-text-secondary, #b0b0b0);
line-height: 1.6;
margin: 0 0 0.5rem;
}
.next-event-location {
font-size: 0.875rem;
color: var(--color-text-secondary, #b0b0b0);
margin: 0;
}
.next-event-action {
border-top: 1px solid rgba(176, 176, 176, 0.1);
padding-top: 1rem;
}
.event-details-link {
display: inline-block;
padding: 0.55rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: #fff;
background: var(--color-accent, #e63946);
border-radius: 6px;
text-decoration: none;
transition: opacity 0.15s, transform 0.15s;
}
.event-details-link:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.no-events-message {
color: var(--color-text-secondary, #b0b0b0);
font-size: 0.95rem;
font-style: italic;
margin: 0;
}
/* Upcoming Events Section */
.upcoming-events-section {
padding: 3rem 2rem;
display: flex;
justify-content: center;
border-top: 1px solid rgba(176, 176, 176, 0.1);
}
.upcoming-events-content {
max-width: 720px;
width: 100%;
}
.upcoming-events-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upcoming-event-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.7rem 1rem;
background: var(--color-card-background, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(176, 176, 176, 0.15));
border-radius: 8px;
transition: border-color 0.15s;
}
.upcoming-event-row:hover {
border-color: var(--color-accent, #e63946);
}
.upcoming-event-date {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text, #eaeaea);
white-space: nowrap;
min-width: 7em;
}
.upcoming-event-title {
flex: 1;
font-size: 0.925rem;
font-weight: 500;
color: var(--color-text, #eaeaea);
}
.upcoming-event-time {
font-size: 0.8rem;
color: var(--color-text-secondary, #b0b0b0);
white-space: nowrap;
}
/* About Section */
.about {
padding: 4rem 2rem;
@@ -286,6 +908,32 @@
padding: 2.5rem 1.25rem;
}
.next-event-section,
.upcoming-events-section {
padding: 2rem 1.25rem;
}
.next-event-card {
padding: 1.25rem;
}
.next-event-title {
font-size: 1.15rem;
}
.next-event-meta {
gap: 0.5rem;
}
.upcoming-event-row {
flex-wrap: wrap;
gap: 0.4rem 0.75rem;
}
.upcoming-event-title {
flex: 1 1 100%;
}
.footer-content {
flex-direction: column;
text-align: center;
+3 -3
View File
@@ -41,9 +41,9 @@
{ label: 'Dashboard', href: '/admin', placeholder: false },
{ label: 'Settings', href: '/admin/settings', placeholder: false },
{ label: 'Branding', href: '/admin/branding', placeholder: false },
{ label: 'Homepage', href: '/admin/homepage', placeholder: true },
{ label: 'Links', href: '/admin/links', placeholder: true },
{ label: 'Events', href: '/admin/events', placeholder: true },
{ label: 'Homepage', href: '/admin/homepage', placeholder: false },
{ label: 'Links', href: '/admin/links', placeholder: false },
{ label: 'Events', href: '/admin/events', placeholder: false },
{ label: 'Assets', href: '/admin/assets', placeholder: false },
{ label: 'Team', href: '/admin/team', placeholder: true }
];
+249
View File
@@ -0,0 +1,249 @@
import { db } from '$lib/server/db';
import { events } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
/**
* Load all events for the current site, ordered by startTime descending (newest first).
*/
export const load: PageServerLoad = async (event) => {
const { site } = event.locals;
if (!site) {
return { events: [] };
}
const eventRows = await db
.select()
.from(events)
.where(eq(events.siteId, site.id))
.orderBy(desc(events.startTime));
return {
events: eventRows
};
};
/**
* Named form actions for CRUD operations on events.
*/
export const actions: Actions = {
// ─── Create ───────────────────────────────────────────────────────────
create: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const title = formData.get('title')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
const eventType = formData.get('eventType')?.toString().trim() || 'screening';
const startTimeStr = formData.get('startTime')?.toString().trim();
const endTimeStr = formData.get('endTime')?.toString().trim() || null;
const timezone = formData.get('timezone')?.toString().trim() || 'America/New_York';
const location = formData.get('location')?.toString().trim() || null;
const externalLink = formData.get('externalLink')?.toString().trim() || null;
const imageCdnKey = formData.get('imageCdnKey')?.toString().trim() || null;
const isPublished = formData.get('isPublished') === 'on';
// Validation
if (!title) {
return { success: false, error: 'Title is required.', action: 'create' };
}
if (!startTimeStr) {
return { success: false, error: 'Start time is required.', action: 'create' };
}
const startTime = new Date(startTimeStr);
if (isNaN(startTime.getTime())) {
return { success: false, error: 'Start time must be a valid date.', action: 'create' };
}
let endTime: Date | null = null;
if (endTimeStr) {
endTime = new Date(endTimeStr);
if (isNaN(endTime.getTime())) {
return { success: false, error: 'End time must be a valid date.', action: 'create' };
}
if (endTime <= startTime) {
return { success: false, error: 'End time must be after start time.', action: 'create' };
}
}
// Validate eventType
const validTypes = ['screening', 'watch_party', 'meetup', 'other'];
const finalEventType = validTypes.includes(eventType) ? eventType : 'screening';
try {
await db.insert(events).values({
siteId: site.id,
title,
description,
eventType: finalEventType,
startTime,
endTime,
timezone,
location,
externalLink,
imageCdnKey,
isPublished
});
return { success: true, action: 'create' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create event.';
return { success: false, error: message, action: 'create' };
}
},
// ─── Update ───────────────────────────────────────────────────────────
update: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
const title = formData.get('title')?.toString().trim();
const description = formData.get('description')?.toString().trim() || null;
const eventType = formData.get('eventType')?.toString().trim() || 'screening';
const startTimeStr = formData.get('startTime')?.toString().trim();
const endTimeStr = formData.get('endTime')?.toString().trim() || null;
const timezone = formData.get('timezone')?.toString().trim() || 'America/New_York';
const location = formData.get('location')?.toString().trim() || null;
const externalLink = formData.get('externalLink')?.toString().trim() || null;
const imageCdnKey = formData.get('imageCdnKey')?.toString().trim() || null;
const isPublished = formData.get('isPublished') === 'on';
if (!id) return { success: false, error: 'Event ID is required.', action: 'update' };
if (!title) return { success: false, error: 'Title is required.', action: 'update' };
if (!startTimeStr) return { success: false, error: 'Start time is required.', action: 'update' };
const startTime = new Date(startTimeStr);
if (isNaN(startTime.getTime())) {
return { success: false, error: 'Start time must be a valid date.', action: 'update' };
}
let endTime: Date | null = null;
if (endTimeStr) {
endTime = new Date(endTimeStr);
if (isNaN(endTime.getTime())) {
return { success: false, error: 'End time must be a valid date.', action: 'update' };
}
if (endTime <= startTime) {
return { success: false, error: 'End time must be after start time.', action: 'update' };
}
}
// Verify siteId matches
const [existing] = await db
.select({ id: events.id, siteId: events.siteId })
.from(events)
.where(eq(events.id, id))
.limit(1);
if (!existing) {
return { success: false, error: 'Event not found.', action: 'update' };
}
if (existing.siteId !== site.id) {
return { success: false, error: 'Event does not belong to this site.', action: 'update' };
}
const validTypes = ['screening', 'watch_party', 'meetup', 'other'];
const finalEventType = validTypes.includes(eventType) ? eventType : 'screening';
try {
await db
.update(events)
.set({
title,
description,
eventType: finalEventType,
startTime,
endTime,
timezone,
location,
externalLink,
imageCdnKey,
isPublished,
updatedAt: new Date()
})
.where(eq(events.id, id));
return { success: true, action: 'update' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update event.';
return { success: false, error: message, action: 'update' };
}
},
// ─── Delete ───────────────────────────────────────────────────────────
delete: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
if (!id) return { success: false, error: 'Event ID is required.', action: 'delete' };
// Verify siteId matches before deleting
const [existing] = await db
.select({ id: events.id, siteId: events.siteId })
.from(events)
.where(eq(events.id, id))
.limit(1);
if (!existing) {
return { success: false, error: 'Event not found.', action: 'delete' };
}
if (existing.siteId !== site.id) {
return { success: false, error: 'Event does not belong to this site.', action: 'delete' };
}
try {
await db.delete(events).where(eq(events.id, id));
return { success: true, action: 'delete' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete event.';
return { success: false, error: message, action: 'delete' };
}
},
// ─── Toggle Publish ───────────────────────────────────────────────────
togglePublish: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
if (!id) return { success: false, error: 'Event ID is required.', action: 'togglePublish' };
// Verify siteId matches
const [existing] = await db
.select({ id: events.id, siteId: events.siteId, isPublished: events.isPublished })
.from(events)
.where(eq(events.id, id))
.limit(1);
if (!existing) {
return { success: false, error: 'Event not found.', action: 'togglePublish' };
}
if (existing.siteId !== site.id) {
return { success: false, error: 'Event does not belong to this site.', action: 'togglePublish' };
}
try {
await db
.update(events)
.set({ isPublished: !existing.isPublished, updatedAt: new Date() })
.where(eq(events.id, id));
return { success: true, action: 'togglePublish' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to toggle publish status.';
return { success: false, error: message, action: 'togglePublish' };
}
}
};
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
import { db } from '$lib/server/db';
import { siteSettings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { SiteSettingsData } from '$lib/shared/types';
/**
* Load current homepage settings from siteSettings.settings.homepage JSON.
* Returns flattened props with sensible defaults.
*/
export const load: PageServerLoad = async (event) => {
const { site } = event.locals;
if (!site) {
return {
heroTitle: '',
heroSubtitle: '',
aboutText: '',
primaryButtonText: '',
primaryButtonLink: '',
showNextEvent: true,
showSchedule: true
};
}
const [row] = await db
.select({ settings: siteSettings.settings })
.from(siteSettings)
.where(eq(siteSettings.siteId, site.id))
.limit(1);
const settings = (row?.settings ?? {}) as Partial<SiteSettingsData>;
const homepage = settings.homepage;
return {
heroTitle: homepage?.heroTitle ?? '',
heroSubtitle: homepage?.heroSubtitle ?? '',
aboutText: homepage?.aboutText ?? '',
primaryButtonText: homepage?.primaryButtonText ?? '',
primaryButtonLink: homepage?.primaryButtonLink ?? '',
showNextEvent: homepage?.showNextEvent ?? true,
showSchedule: homepage?.showSchedule ?? true
};
};
/**
* Form action: saves homepage content into siteSettings.settings.homepage JSON.
* All fields are optional. If primaryButtonLink is provided, it must start with
* http://, https://, or /.
*/
export const actions: Actions = {
default: async (event) => {
const { site } = event.locals;
if (!site) {
return { success: false, error: 'No site context found.' };
}
const formData = await event.request.formData();
const heroTitle = formData.get('heroTitle')?.toString().trim() ?? '';
const heroSubtitle = formData.get('heroSubtitle')?.toString().trim() ?? '';
const aboutText = formData.get('aboutText')?.toString().trim() ?? '';
const primaryButtonText = formData.get('primaryButtonText')?.toString().trim() ?? '';
const primaryButtonLink = formData.get('primaryButtonLink')?.toString().trim() ?? '';
const showNextEvent = formData.get('showNextEvent') === 'on';
const showSchedule = formData.get('showSchedule') === 'on';
// Validate primaryButtonLink if provided
if (
primaryButtonLink &&
!primaryButtonLink.startsWith('http://') &&
!primaryButtonLink.startsWith('https://') &&
!primaryButtonLink.startsWith('/')
) {
return {
success: false,
error: 'Primary button link must start with http://, https://, or /.',
field: 'primaryButtonLink'
};
}
try {
// Read current settings to preserve other keys (branding, theme, layout)
const [row] = await db
.select({ settings: siteSettings.settings })
.from(siteSettings)
.where(eq(siteSettings.siteId, site.id))
.limit(1);
const currentSettings = (row?.settings ?? {}) as Record<string, unknown>;
const currentHomepage = (currentSettings.homepage ?? {}) as Record<string, unknown>;
// Merge homepage settings
const homepage = {
...currentHomepage,
heroTitle,
heroSubtitle,
aboutText,
primaryButtonText,
primaryButtonLink,
showNextEvent,
showSchedule
};
// Build final settings object preserving all other keys
const updatedSettings = {
...currentSettings,
homepage
};
// Upsert into siteSettings
await db
.insert(siteSettings)
.values({
siteId: site.id,
settings: updatedSettings
})
.onConflictDoUpdate({
target: siteSettings.siteId,
set: {
settings: updatedSettings,
updatedAt: new Date()
}
});
return { success: true };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save homepage settings.';
return { success: false, error: message };
}
}
};
+435
View File
@@ -0,0 +1,435 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
import { enhance } from '$app/forms';
let { data, form }: { data: PageData; form: ActionData } = $props();
/** Whether the form is currently being submitted */
let saving = $state(false);
/** Feedback message shown after save attempt */
let feedback = $state<{ type: 'success' | 'error'; message: string } | null>(null);
// Clear feedback when form action data changes (new submission)
$effect(() => {
if (form) {
saving = false;
if (form.success) {
feedback = { type: 'success', message: 'Homepage settings saved.' };
} else if (form.error) {
feedback = { type: 'error', message: form.error };
}
}
});
/** Called by use:enhance before form submission */
function handleEnhance() {
saving = true;
feedback = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ({ result }: any) => {
saving = false;
if (result.type === 'success') {
// The $effect above will set feedback from form data
} else if (result.type === 'failure') {
feedback = {
type: 'error',
message: (result.data?.error as string) ?? 'Failed to save homepage settings.'
};
} else if (result.type === 'error') {
feedback = {
type: 'error',
message: 'A network error occurred. Please try again.'
};
}
};
}
</script>
<svelte:head>
<title>Homepage — Admin</title>
</svelte:head>
<div class="homepage-page">
<h1 class="page-title">Homepage</h1>
<p class="page-desc">Customize your public homepage hero section, about text, and call-to-action button.</p>
<!-- Feedback message -->
{#if feedback}
<div
class="feedback"
class:feedback--success={feedback.type === 'success'}
class:feedback--error={feedback.type === 'error'}
role="alert"
>
{#if feedback.type === 'success'}{:else}{/if}
{feedback.message}
<button class="feedback-close" onclick={() => (feedback = null)}>×</button>
</div>
{/if}
<form method="POST" use:enhance={handleEnhance} class="homepage-form">
<fieldset disabled={saving}>
<!-- Hero Title -->
<div class="form-group">
<label for="heroTitle" class="form-label">Hero Title</label>
<input
type="text"
id="heroTitle"
name="heroTitle"
class="form-input"
value={data.heroTitle}
maxlength={100}
placeholder="Welcome to Our Community"
/>
<p class="form-help">The main headline displayed in the hero section. Max 100 characters.</p>
</div>
<!-- Hero Subtitle -->
<div class="form-group">
<label for="heroSubtitle" class="form-label">Hero Subtitle</label>
<input
type="text"
id="heroSubtitle"
name="heroSubtitle"
class="form-input"
value={data.heroSubtitle}
maxlength={200}
placeholder="A community for creators, gamers, and builders."
/>
<p class="form-help">Supporting text shown below the hero title. Max 200 characters.</p>
</div>
<!-- About Text -->
<div class="form-group">
<label for="aboutText" class="form-label">About Text</label>
<textarea
id="aboutText"
name="aboutText"
class="form-textarea"
value={data.aboutText}
maxlength={2000}
rows={6}
placeholder="Tell visitors what your community is about. This text appears in the About section beneath the hero. You can describe your mission, values, or what new members can expect."
></textarea>
<p class="form-help">A longer description of your community. Max 2000 characters. Leave empty to hide the About section.</p>
</div>
<!-- Section Divider: CTA -->
<h2 class="section-title">Call-to-Action Button</h2>
<!-- Primary Button Text -->
<div class="form-group">
<label for="primaryButtonText" class="form-label">Button Text</label>
<input
type="text"
id="primaryButtonText"
name="primaryButtonText"
class="form-input"
value={data.primaryButtonText}
maxlength={50}
placeholder="Join Discord"
/>
<p class="form-help">The label on the primary action button (e.g. "Join Discord", "Learn More"). Max 50 chars.</p>
</div>
<!-- Primary Button Link -->
<div class="form-group">
<label for="primaryButtonLink" class="form-label">Button Link</label>
<input
type="text"
id="primaryButtonLink"
name="primaryButtonLink"
class="form-input"
value={data.primaryButtonLink}
placeholder="https://discord.gg/..."
/>
<p class="form-help">
The URL the button links to. Must start with <code>http://</code>, <code>https://</code>, or <code>/</code> for internal paths.
</p>
</div>
<!-- Section Divider: Toggles -->
<h2 class="section-title">Page Sections</h2>
<!-- Show Next Event -->
<div class="form-group checkbox-group">
<label class="form-label checkbox-label">
<input
type="checkbox"
id="showNextEvent"
name="showNextEvent"
checked={data.showNextEvent}
class="form-checkbox"
/>
<span>Show Next Event Section</span>
</label>
<p class="form-help">Display the upcoming event section on the homepage.</p>
</div>
<!-- Show Schedule -->
<div class="form-group checkbox-group">
<label class="form-label checkbox-label">
<input
type="checkbox"
id="showSchedule"
name="showSchedule"
checked={data.showSchedule}
class="form-checkbox"
/>
<span>Show Schedule Section</span>
</label>
<p class="form-help">Display the event schedule section on the homepage.</p>
</div>
<!-- Actions -->
<div class="form-actions">
<button type="submit" class="save-btn" disabled={saving}>
{#if saving}
<span class="spinner"></span>
Saving…
{:else}
Save Homepage
{/if}
</button>
<a href="/" class="preview-link" target="_blank" rel="noopener noreferrer">
↗ View Site
</a>
</div>
</fieldset>
</form>
</div>
<style>
.homepage-page {
max-width: 640px;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.25rem;
color: #1a1a2e;
}
.page-desc {
color: #666;
margin: 0 0 1.5rem;
font-size: 0.925rem;
}
/* ── Feedback ───────────────────────────────────────────────── */
.feedback {
padding: 0.7rem 1rem;
border-radius: 6px;
margin-bottom: 1.25rem;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.feedback--success {
background: #daf5e0;
color: #1a6b30;
border: 1px solid #a3d9b1;
}
.feedback--error {
background: #fde8e8;
color: #9b1c1c;
border: 1px solid #f4b2b2;
}
.feedback-close {
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: inherit;
opacity: 0.6;
padding: 0 0.25rem;
margin-left: auto;
}
.feedback-close:hover {
opacity: 1;
}
/* ── Form ───────────────────────────────────────────────────── */
.homepage-form fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.35rem;
color: #1a1a2e;
}
.form-input {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.925rem;
border: 1px solid #d0d0d6;
border-radius: 6px;
background: #fff;
color: #1a1a2e;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
}
.form-input:disabled {
background: #f5f5f7;
color: #999;
}
.form-textarea {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.925rem;
font-family: inherit;
border: 1px solid #d0d0d6;
border-radius: 6px;
background: #fff;
color: #1a1a2e;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
resize: vertical;
line-height: 1.6;
}
.form-textarea:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
}
.form-textarea:disabled {
background: #f5f5f7;
color: #999;
}
.form-help {
font-size: 0.8rem;
color: #888;
margin: 0.3rem 0 0;
}
.form-help code {
background: #f0f0f3;
padding: 0.1em 0.35em;
border-radius: 3px;
font-size: 0.85em;
}
/* ── Checkbox ────────────────────────────────────────────────── */
.checkbox-group {
margin-bottom: 1.25rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #1a7f37;
}
/* ── Section Title ──────────────────────────────────────────── */
.section-title {
font-size: 1.15rem;
font-weight: 700;
color: #1a1a2e;
margin: 2rem 0 1rem;
padding-top: 1.25rem;
border-top: 1px solid #e0e0e6;
}
/* ── Actions ────────────────────────────────────────────────── */
.form-actions {
margin-top: 1.75rem;
padding-top: 1.25rem;
border-top: 1px solid #e0e0e6;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.save-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.5rem;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: #1a7f37;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.save-btn:hover {
background: #14682c;
}
.save-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.preview-link {
font-size: 0.875rem;
font-weight: 500;
color: #58a6ff;
text-decoration: none;
transition: color 0.15s;
}
.preview-link:hover {
color: #388bfd;
text-decoration: underline;
}
/* Simple CSS spinner */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+232
View File
@@ -0,0 +1,232 @@
import { db } from '$lib/server/db';
import { navLinks, socialLinks } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
import type { Actions } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
/**
* Load nav links and social links for the current site.
*/
export const load: PageServerLoad = async (event) => {
const { site } = event.locals;
if (!site) {
return { navLinks: [], socialLinks: [] };
}
const navRows = await db
.select()
.from(navLinks)
.where(eq(navLinks.siteId, site.id))
.orderBy(asc(navLinks.position), asc(navLinks.sortOrder));
const socialRows = await db
.select()
.from(socialLinks)
.where(eq(socialLinks.siteId, site.id))
.orderBy(asc(socialLinks.sortOrder));
return {
navLinks: navRows,
socialLinks: socialRows
};
};
/**
* Named form actions for CRUD operations on nav links and social links.
*/
export const actions: Actions = {
// ─── Nav Links ────────────────────────────────────────────────────────
createNavLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const label = formData.get('label')?.toString().trim();
const url = formData.get('url')?.toString().trim();
const position = formData.get('position')?.toString().trim() ?? 'header';
const sortOrder = parseInt(formData.get('sortOrder')?.toString() ?? '0', 10) || 0;
const isExternal = formData.get('isExternal') === 'on';
if (!label || !url) {
return { success: false, error: 'Label and URL are required.', action: 'createNavLink' };
}
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('/')) {
return { success: false, error: 'URL must start with http://, https://, or /.', action: 'createNavLink' };
}
try {
await db.insert(navLinks).values({
siteId: site.id,
label,
url,
position,
sortOrder,
isExternal
});
return { success: true, action: 'createNavLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create nav link.';
return { success: false, error: message, action: 'createNavLink' };
}
},
updateNavLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
const label = formData.get('label')?.toString().trim();
const url = formData.get('url')?.toString().trim();
const position = formData.get('position')?.toString().trim() ?? 'header';
const sortOrder = parseInt(formData.get('sortOrder')?.toString() ?? '0', 10) || 0;
const isExternal = formData.get('isExternal') === 'on';
if (!id) return { success: false, error: 'Link ID is required.', action: 'updateNavLink' };
if (!label || !url) {
return { success: false, error: 'Label and URL are required.', action: 'updateNavLink' };
}
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('/')) {
return { success: false, error: 'URL must start with http://, https://, or /.', action: 'updateNavLink' };
}
try {
await db
.update(navLinks)
.set({ label, url, position, sortOrder, isExternal, updatedAt: new Date() })
.where(eq(navLinks.id, id));
return { success: true, action: 'updateNavLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update nav link.';
return { success: false, error: message, action: 'updateNavLink' };
}
},
deleteNavLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
if (!id) return { success: false, error: 'Link ID is required.', action: 'deleteNavLink' };
try {
// Verify siteId matches before deleting
const [existing] = await db
.select({ id: navLinks.id })
.from(navLinks)
.where(eq(navLinks.id, id))
.limit(1);
if (!existing) {
return { success: false, error: 'Link not found.', action: 'deleteNavLink' };
}
await db.delete(navLinks).where(eq(navLinks.id, id));
return { success: true, action: 'deleteNavLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete nav link.';
return { success: false, error: message, action: 'deleteNavLink' };
}
},
// ─── Social Links ─────────────────────────────────────────────────────
createSocialLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const platform = formData.get('platform')?.toString().trim();
const label = formData.get('label')?.toString().trim() || null;
const url = formData.get('url')?.toString().trim();
const sortOrder = parseInt(formData.get('sortOrder')?.toString() ?? '0', 10) || 0;
if (!platform || !url) {
return { success: false, error: 'Platform and URL are required.', action: 'createSocialLink' };
}
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('/')) {
return { success: false, error: 'URL must start with http://, https://, or /.', action: 'createSocialLink' };
}
try {
await db.insert(socialLinks).values({
siteId: site.id,
platform,
label,
url,
sortOrder
});
return { success: true, action: 'createSocialLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create social link.';
return { success: false, error: message, action: 'createSocialLink' };
}
},
updateSocialLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
const platform = formData.get('platform')?.toString().trim();
const label = formData.get('label')?.toString().trim() || null;
const url = formData.get('url')?.toString().trim();
const sortOrder = parseInt(formData.get('sortOrder')?.toString() ?? '0', 10) || 0;
if (!id) return { success: false, error: 'Link ID is required.', action: 'updateSocialLink' };
if (!platform || !url) {
return { success: false, error: 'Platform and URL are required.', action: 'updateSocialLink' };
}
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('/')) {
return { success: false, error: 'URL must start with http://, https://, or /.', action: 'updateSocialLink' };
}
try {
await db
.update(socialLinks)
.set({ platform, label, url, sortOrder, updatedAt: new Date() })
.where(eq(socialLinks.id, id));
return { success: true, action: 'updateSocialLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update social link.';
return { success: false, error: message, action: 'updateSocialLink' };
}
},
deleteSocialLink: async (event) => {
const { site } = event.locals;
if (!site) return { success: false, error: 'No site context found.' };
const formData = await event.request.formData();
const id = formData.get('id')?.toString();
if (!id) return { success: false, error: 'Link ID is required.', action: 'deleteSocialLink' };
try {
const [existing] = await db
.select({ id: socialLinks.id })
.from(socialLinks)
.where(eq(socialLinks.id, id))
.limit(1);
if (!existing) {
return { success: false, error: 'Link not found.', action: 'deleteSocialLink' };
}
await db.delete(socialLinks).where(eq(socialLinks.id, id));
return { success: true, action: 'deleteSocialLink' };
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete social link.';
return { success: false, error: message, action: 'deleteSocialLink' };
}
}
};
File diff suppressed because it is too large Load Diff