Merge branch 'zasderq/dev' of https://github.com/dallensmith/the-collective-hub into kungraseri/dev
This commit is contained in:
@@ -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
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user