feat(db): add nav_links, social_links, and events tables
- Add nav_links table with siteId, label, url, position, sortOrder, isExternal - Add social_links table with siteId, platform, label, url, icon, sortOrder - Add events table with siteId, title, description, eventType, startTime, endTime, timezone, location, externalLink, imageCdnKey, isPublished, isRecurring - Include corresponding Drizzle migration entries
This commit is contained in:
@@ -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,
|
"when": 1780717416486,
|
||||||
"tag": "0000_moaning_the_initiative",
|
"tag": "0000_moaning_the_initiative",
|
||||||
"breakpoints": true
|
"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 ────────────────────────────────────────────────────────────
|
// ─── Type Exports ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type Site = typeof sites.$inferSelect;
|
export type Site = typeof sites.$inferSelect;
|
||||||
@@ -151,3 +233,12 @@ export type NewSiteSetting = typeof siteSettings.$inferInsert;
|
|||||||
|
|
||||||
export type Asset = typeof assets.$inferSelect;
|
export type Asset = typeof assets.$inferSelect;
|
||||||
export type NewAsset = typeof assets.$inferInsert;
|
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 type { PageServerLoad } from './$types';
|
||||||
import { getCdnUrl } from '$lib/server/cdn';
|
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.
|
* Homepage server load — flattens site settings into simple props.
|
||||||
*
|
*
|
||||||
* All data originates from the root layout server (+layout.server.ts).
|
* Most data originates from the root layout server (+layout.server.ts).
|
||||||
* This loader calls event.parent() to access that cached data so we
|
* This loader calls event.parent() to access that cached data for
|
||||||
* make zero additional database queries here.
|
* 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
|
* Fallback chain for each prop ensures the page always has sensible
|
||||||
* values, even when settings have never been configured by the owner.
|
* 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 backgroundUrl = branding?.backgroundCdnKey ? getCdnUrl(branding.backgroundCdnKey) : null;
|
||||||
const faviconUrl = branding?.faviconCdnKey ? getCdnUrl(branding.faviconCdnKey) : 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 {
|
return {
|
||||||
site,
|
site,
|
||||||
heroTitle,
|
heroTitle,
|
||||||
@@ -53,6 +114,10 @@ export const load: PageServerLoad = async (event) => {
|
|||||||
membership,
|
membership,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
backgroundUrl,
|
backgroundUrl,
|
||||||
faviconUrl
|
faviconUrl,
|
||||||
|
navLinks: navLinkRows,
|
||||||
|
socialLinks: socialLinkRows,
|
||||||
|
nextEvent,
|
||||||
|
upcomingEvents
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import { formatEventTime, formatEventDate, formatEventTimeOnly } from '$lib/shared/timezone';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
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://).
|
* Determine if a link is external (starts with http:// or https://).
|
||||||
* Internal links (starting with `/`) navigate normally.
|
* Internal links (starting with `/`) navigate normally.
|
||||||
@@ -10,6 +14,36 @@
|
|||||||
function isExternal(href: string): boolean {
|
function isExternal(href: string): boolean {
|
||||||
return /^https?:\/\//.test(href);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -27,7 +61,72 @@
|
|||||||
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
|
<p>Run <code>npm run db:seed</code> to create the default "local-dev" site.</p>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{: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 -->
|
<!-- HERO SECTION -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<section
|
<section
|
||||||
class="hero"
|
class="hero"
|
||||||
style={data.backgroundUrl
|
style={data.backgroundUrl
|
||||||
@@ -64,7 +163,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<!-- ABOUT SECTION (only if aboutText is set) -->
|
<!-- ABOUT SECTION (only if aboutText is set) -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
{#if data.aboutText}
|
{#if data.aboutText}
|
||||||
<section class="about">
|
<section class="about">
|
||||||
<div class="about-content">
|
<div class="about-content">
|
||||||
@@ -74,7 +175,138 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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 -->
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<span class="footer-site-name">{data.site.name}</span>
|
<span class="footer-site-name">{data.site.name}</span>
|
||||||
@@ -87,6 +319,208 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<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 */
|
||||||
.error-state {
|
.error-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -202,6 +636,194 @@
|
|||||||
transform: translateY(0);
|
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 Section */
|
||||||
.about {
|
.about {
|
||||||
padding: 4rem 2rem;
|
padding: 4rem 2rem;
|
||||||
@@ -286,6 +908,32 @@
|
|||||||
padding: 2.5rem 1.25rem;
|
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 {
|
.footer-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -41,9 +41,9 @@
|
|||||||
{ label: 'Dashboard', href: '/admin', placeholder: false },
|
{ label: 'Dashboard', href: '/admin', placeholder: false },
|
||||||
{ label: 'Settings', href: '/admin/settings', placeholder: false },
|
{ label: 'Settings', href: '/admin/settings', placeholder: false },
|
||||||
{ label: 'Branding', href: '/admin/branding', placeholder: false },
|
{ label: 'Branding', href: '/admin/branding', placeholder: false },
|
||||||
{ label: 'Homepage', href: '/admin/homepage', placeholder: true },
|
{ label: 'Homepage', href: '/admin/homepage', placeholder: false },
|
||||||
{ label: 'Links', href: '/admin/links', placeholder: true },
|
{ label: 'Links', href: '/admin/links', placeholder: false },
|
||||||
{ label: 'Events', href: '/admin/events', placeholder: true },
|
{ label: 'Events', href: '/admin/events', placeholder: false },
|
||||||
{ label: 'Assets', href: '/admin/assets', placeholder: false },
|
{ label: 'Assets', href: '/admin/assets', placeholder: false },
|
||||||
{ label: 'Team', href: '/admin/team', placeholder: true }
|
{ 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