From 41643912f73ec371013b4e997a65c6f5f92acdb5 Mon Sep 17 00:00:00 2001 From: bwmp Date: Tue, 28 Jul 2026 12:35:49 -0700 Subject: [PATCH 01/15] feat: add server list with voting Co-Authored-By: Claude Fable 5 --- .../migrations/0006_reflective_tarantula.sql | 56 ++ drizzle/migrations/meta/0006_snapshot.json | 879 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + drizzle/schema.ts | 118 +++ src/components/Elements/MobileNav.tsx | 7 + src/components/Elements/Nav.tsx | 7 + src/components/ServerList/BBCodeEditor.tsx | 402 ++++++++ src/components/ServerList/ServerCard.tsx | 130 +++ src/components/ServerList/ServerControls.tsx | 131 +++ src/components/ServerList/ServerForm.tsx | 316 +++++++ src/components/ServerList/StatusBadge.tsx | 42 + src/components/ServerList/Turnstile.tsx | 80 ++ src/components/ServerList/VoteSection.tsx | 122 +++ src/routes/serverlist/[slug]/edit/index.tsx | 56 ++ src/routes/serverlist/[slug]/index.tsx | 281 ++++++ src/routes/serverlist/index.tsx | 265 ++++++ src/routes/serverlist/submit/index.tsx | 51 + src/util/serverlist/actions.ts | 301 ++++++ src/util/serverlist/bbcode.ts | 128 +++ src/util/serverlist/constants.ts | 70 ++ src/util/serverlist/queries.ts | 166 ++++ src/util/serverlist/status.ts | 108 +++ src/util/serverlist/turnstile.ts | 45 + src/util/serverlist/validation.ts | 175 ++++ src/util/serverlist/votifier.ts | 128 +++ 25 files changed, 4071 insertions(+) create mode 100644 drizzle/migrations/0006_reflective_tarantula.sql create mode 100644 drizzle/migrations/meta/0006_snapshot.json create mode 100644 src/components/ServerList/BBCodeEditor.tsx create mode 100644 src/components/ServerList/ServerCard.tsx create mode 100644 src/components/ServerList/ServerControls.tsx create mode 100644 src/components/ServerList/ServerForm.tsx create mode 100644 src/components/ServerList/StatusBadge.tsx create mode 100644 src/components/ServerList/Turnstile.tsx create mode 100644 src/components/ServerList/VoteSection.tsx create mode 100644 src/routes/serverlist/[slug]/edit/index.tsx create mode 100644 src/routes/serverlist/[slug]/index.tsx create mode 100644 src/routes/serverlist/index.tsx create mode 100644 src/routes/serverlist/submit/index.tsx create mode 100644 src/util/serverlist/actions.ts create mode 100644 src/util/serverlist/bbcode.ts create mode 100644 src/util/serverlist/constants.ts create mode 100644 src/util/serverlist/queries.ts create mode 100644 src/util/serverlist/status.ts create mode 100644 src/util/serverlist/turnstile.ts create mode 100644 src/util/serverlist/validation.ts create mode 100644 src/util/serverlist/votifier.ts diff --git a/drizzle/migrations/0006_reflective_tarantula.sql b/drizzle/migrations/0006_reflective_tarantula.sql new file mode 100644 index 00000000..de590680 --- /dev/null +++ b/drizzle/migrations/0006_reflective_tarantula.sql @@ -0,0 +1,56 @@ +CREATE TABLE `serverReports` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `serverId` integer NOT NULL, + `reason` text NOT NULL, + `details` text, + `reporterId` text, + `ip` text, + `resolved` integer DEFAULT false NOT NULL, + `createdAt` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`serverId`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`reporterId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `serverReports_server_idx` ON `serverReports` (`serverId`);--> statement-breakpoint +CREATE INDEX `serverReports_resolved_idx` ON `serverReports` (`resolved`);--> statement-breakpoint +CREATE TABLE `serverVotes` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `serverId` integer NOT NULL, + `username` text NOT NULL, + `ip` text, + `votifierDelivered` integer DEFAULT false NOT NULL, + `createdAt` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`serverId`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `serverVotes_server_created_idx` ON `serverVotes` (`serverId`,`createdAt`);--> statement-breakpoint +CREATE INDEX `serverVotes_username_idx` ON `serverVotes` (`username`);--> statement-breakpoint +CREATE TABLE `servers` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `ownerId` text, + `name` text NOT NULL, + `slug` text NOT NULL, + `description` text NOT NULL, + `shortDescription` text, + `edition` text DEFAULT 'java' NOT NULL, + `javaHost` text, + `javaPort` integer, + `bedrockHost` text, + `bedrockPort` integer, + `website` text, + `discord` text, + `bannerUrl` text, + `tags` text DEFAULT '[]' NOT NULL, + `votifierHost` text, + `votifierPort` integer, + `votifierToken` text, + `featured` integer DEFAULT false NOT NULL, + `featuredUntil` integer, + `createdAt` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, + `updatedAt` integer DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY (`ownerId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `servers_slug_unique` ON `servers` (`slug`);--> statement-breakpoint +CREATE INDEX `servers_owner_idx` ON `servers` (`ownerId`);--> statement-breakpoint +CREATE INDEX `servers_featured_idx` ON `servers` (`featured`); \ No newline at end of file diff --git a/drizzle/migrations/meta/0006_snapshot.json b/drizzle/migrations/meta/0006_snapshot.json new file mode 100644 index 00000000..2bb6fdc9 --- /dev/null +++ b/drizzle/migrations/meta/0006_snapshot.json @@ -0,0 +1,879 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f00a2455-518b-49f1-a743-83af0cc41510", + "prevId": "1fe8cfa4-5a49-4688-8103-253f339251bf", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "presets": { + "name": "presets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "colorVector": { + "name": "colorVector", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "saves": { + "name": "saves", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "pending": { + "name": "pending", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "presets_preset_unique": { + "name": "presets_preset_unique", + "columns": [ + "preset" + ], + "isUnique": true + } + }, + "foreignKeys": { + "presets_userId_user_id_fk": { + "name": "presets_userId_user_id_fk", + "tableFrom": "presets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "savedPresets": { + "name": "savedPresets", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "presetId": { + "name": "presetId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "savedAt": { + "name": "savedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "savedPresets_userId_user_id_fk": { + "name": "savedPresets_userId_user_id_fk", + "tableFrom": "savedPresets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "savedPresets_presetId_presets_id_fk": { + "name": "savedPresets_presetId_presets_id_fk", + "tableFrom": "savedPresets", + "tableTo": "presets", + "columnsFrom": [ + "presetId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "savedPresets_userId_presetId_pk": { + "columns": [ + "userId", + "presetId" + ], + "name": "savedPresets_userId_presetId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverReports": { + "name": "serverReports", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "serverId": { + "name": "serverId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporterId": { + "name": "reporterId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "serverReports_server_idx": { + "name": "serverReports_server_idx", + "columns": [ + "serverId" + ], + "isUnique": false + }, + "serverReports_resolved_idx": { + "name": "serverReports_resolved_idx", + "columns": [ + "resolved" + ], + "isUnique": false + } + }, + "foreignKeys": { + "serverReports_serverId_servers_id_fk": { + "name": "serverReports_serverId_servers_id_fk", + "tableFrom": "serverReports", + "tableTo": "servers", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "serverReports_reporterId_user_id_fk": { + "name": "serverReports_reporterId_user_id_fk", + "tableFrom": "serverReports", + "tableTo": "user", + "columnsFrom": [ + "reporterId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "serverVotes": { + "name": "serverVotes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "serverId": { + "name": "serverId", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "votifierDelivered": { + "name": "votifierDelivered", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "serverVotes_server_created_idx": { + "name": "serverVotes_server_created_idx", + "columns": [ + "serverId", + "createdAt" + ], + "isUnique": false + }, + "serverVotes_username_idx": { + "name": "serverVotes_username_idx", + "columns": [ + "username" + ], + "isUnique": false + } + }, + "foreignKeys": { + "serverVotes_serverId_servers_id_fk": { + "name": "serverVotes_serverId_servers_id_fk", + "tableFrom": "serverVotes", + "tableTo": "servers", + "columnsFrom": [ + "serverId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ownerId": { + "name": "ownerId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shortDescription": { + "name": "shortDescription", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "edition": { + "name": "edition", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'java'" + }, + "javaHost": { + "name": "javaHost", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "javaPort": { + "name": "javaPort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrockHost": { + "name": "bedrockHost", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrockPort": { + "name": "bedrockPort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discord": { + "name": "discord", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bannerUrl": { + "name": "bannerUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "votifierHost": { + "name": "votifierHost", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "votifierPort": { + "name": "votifierPort", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "votifierToken": { + "name": "votifierToken", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "featuredUntil": { + "name": "featuredUntil", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "servers_slug_unique": { + "name": "servers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "servers_owner_idx": { + "name": "servers_owner_idx", + "columns": [ + "ownerId" + ], + "isUnique": false + }, + "servers_featured_idx": { + "name": "servers_featured_idx", + "columns": [ + "featured" + ], + "isUnique": false + } + }, + "foreignKeys": { + "servers_ownerId_user_id_fk": { + "name": "servers_ownerId_user_id_fk", + "tableFrom": "servers", + "tableTo": "user", + "columnsFrom": [ + "ownerId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "privatePresets": { + "name": "privatePresets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index a53884e4..e88dae29 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1770347052461, "tag": "0005_exotic_iron_patriot", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1780222656777, + "tag": "0006_reflective_tarantula", + "breakpoints": true } ] } diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 8553a48f..9d03a902 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -3,11 +3,16 @@ import { integer, text, primaryKey, + index, } from 'drizzle-orm/sqlite-core'; import type { AdapterAccountType } from '@auth/qwik/adapters'; import { sql } from 'drizzle-orm/sql/sql'; import { rgbPreset } from '../src/util/rgb/presets'; import { Settings } from '../src/routes/layout'; +import type { + ServerEdition, + ServerTag, +} from '../src/util/serverlist/constants'; // -------------------- User -------------------- export const users = sqliteTable('user', { @@ -148,3 +153,116 @@ export const savedPresets = sqliteTable( }, (t) => [primaryKey({ columns: [t.userId, t.presetId] })] ); + +// -------------------- Server List -------------------- +export const servers = sqliteTable( + 'servers', + { + id: integer('id').primaryKey({ autoIncrement: true }), + // Owner who submitted the listing. Kept on delete so listings survive + // account removal, just unowned (and therefore admin-only to manage). + ownerId: text('ownerId').references(() => users.id, { + onDelete: 'set null', + }), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + description: text('description').notNull(), + shortDescription: text('shortDescription'), + edition: text('edition').$type().notNull().default('java'), + // Connection details. Java/Bedrock hosts are independent so a "both" + // listing can point each edition at a different address. + javaHost: text('javaHost'), + javaPort: integer('javaPort'), + bedrockHost: text('bedrockHost'), + bedrockPort: integer('bedrockPort'), + website: text('website'), + discord: text('discord'), + bannerUrl: text('bannerUrl'), + tags: text('tags', { mode: 'json' }) + .$type() + .notNull() + .default(sql`'[]'`), + // NuVotifier (Votifier v2) token-protocol delivery details. + votifierHost: text('votifierHost'), + votifierPort: integer('votifierPort'), + votifierToken: text('votifierToken'), + // Sponsored / featured placement (admin-granted in v1). + featured: integer('featured', { mode: 'boolean' }) + .default(false) + .notNull(), + featuredUntil: integer('featuredUntil', { mode: 'timestamp_ms' }), + createdAt: integer('createdAt', { mode: 'timestamp_ms' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: integer('updatedAt', { mode: 'timestamp_ms' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (t) => [ + index('servers_owner_idx').on(t.ownerId), + index('servers_featured_idx').on(t.featured), + ] +); + +export type Server = typeof servers.$inferSelect; +export type ServerInsert = typeof servers.$inferInsert; +export interface ServerWithVotes extends Server { + monthlyVotes: number; + totalVotes: number; + owner?: User | null; +} + +// -------------------- Server Votes -------------------- +export const serverVotes = sqliteTable( + 'serverVotes', + { + id: integer('id').primaryKey({ autoIncrement: true }), + serverId: integer('serverId') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + // Minecraft username supplied at vote time (voting requires no login). + username: text('username').notNull(), + ip: text('ip'), + votifierDelivered: integer('votifierDelivered', { mode: 'boolean' }) + .default(false) + .notNull(), + createdAt: integer('createdAt', { mode: 'timestamp_ms' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (t) => [ + index('serverVotes_server_created_idx').on(t.serverId, t.createdAt), + index('serverVotes_username_idx').on(t.username), + ] +); + +export type ServerVote = typeof serverVotes.$inferSelect; + +// -------------------- Server Reports -------------------- +export const serverReports = sqliteTable( + 'serverReports', + { + id: integer('id').primaryKey({ autoIncrement: true }), + serverId: integer('serverId') + .notNull() + .references(() => servers.id, { onDelete: 'cascade' }), + reason: text('reason').notNull(), + details: text('details'), + reporterId: text('reporterId').references(() => users.id, { + onDelete: 'set null', + }), + ip: text('ip'), + resolved: integer('resolved', { mode: 'boolean' }) + .default(false) + .notNull(), + createdAt: integer('createdAt', { mode: 'timestamp_ms' }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (t) => [ + index('serverReports_server_idx').on(t.serverId), + index('serverReports_resolved_idx').on(t.resolved), + ] +); + +export type ServerReport = typeof serverReports.$inferSelect; diff --git a/src/components/Elements/MobileNav.tsx b/src/components/Elements/MobileNav.tsx index 083a83a4..dfd93a75 100644 --- a/src/components/Elements/MobileNav.tsx +++ b/src/components/Elements/MobileNav.tsx @@ -125,6 +125,13 @@ export default component$(() => { {' '} {t('nav.resources.banner.title@@Banner Generator')} + + {' '} + {t('nav.resources.serverList.title@@Server List')} + { {' '} {t('nav.resources.banner.title@@Banner Generator')} + + {' '} + {t('nav.resources.serverList.title@@Server List')} + void>; + id?: string; + maxLength?: number; + placeholder?: string; +} + +type Menu = 'link' | 'image' | 'color' | 'size' | null; + +const SIZE_PRESETS = [ + { label: 'Small', value: 12 }, + { label: 'Normal', value: 16 }, + { label: 'Large', value: 22 }, + { label: 'Huge', value: 28 }, +]; + +const tbBtn = + 'lum-btn lum-btn-p-1 lum-bg-transparent hover:lum-bg-lum-input-bg/60 rounded-lum-1'; + +export default component$( + ({ value, onChange$, id, maxLength, placeholder }) => { + const ref = useSignal(); + const rootRef = useSignal(); + const showPreview = useSignal(false); + const current = useSignal(value); + + // Which popover is open, and the textarea selection captured when it opened. + const menu = useSignal(null); + const selStart = useSignal(0); + const selEnd = useSignal(0); + + // Popover field state + const color = useSignal('#54daf4'); + const linkUrl = useSignal('https://'); + const imgUrl = useSignal('https://'); + + // Close popovers on outside click. + useOnDocument( + 'pointerdown', + $((e) => { + const target = e.target as Node | null; + if (rootRef.value && target && !rootRef.value.contains(target)) + menu.value = null; + }) + ); + + // Core insert: wrap [start,end) of the textarea with open/close (or a placeholder). + const insertAt = $( + async ( + open: string, + close: string, + placeholderText: string, + start: number, + end: number + ) => { + const el = ref.value; + if (!el) return; + const text = el.value; + const selected = text.slice(start, end) || placeholderText; + const next = + text.slice(0, start) + open + selected + close + text.slice(end); + + el.value = next; + current.value = next; + await onChange$(next); + + const innerStart = start + open.length; + const innerEnd = innerStart + selected.length; + el.focus(); + el.setSelectionRange(innerStart, innerEnd); + } + ); + + // Direct (no-input) tags: operate on the live selection. + const wrap = $((open: string, close: string, placeholderText = '') => { + const el = ref.value; + if (!el) return; + return insertAt( + open, + close, + placeholderText, + el.selectionStart, + el.selectionEnd + ); + }); + + // Open a popover, capturing the current selection first (the buttons use + // preventdefault:mousedown so the textarea keeps focus + selection). + const openMenu = $((which: Menu) => { + const el = ref.value; + if (el) { + selStart.value = el.selectionStart; + selEnd.value = el.selectionEnd; + } + menu.value = menu.value === which ? null : which; + }); + + const applyFromMenu = $( + async (open: string, close: string, placeholderText: string) => { + await insertAt( + open, + close, + placeholderText, + selStart.value, + selEnd.value + ); + menu.value = null; + } + ); + + return ( +
+
+ {/* Toolbar */} +
+ + + + + + + + + + + + + + + + + + + + +
+ + {/* Popovers */} + {menu.value === 'link' && ( +
+ Insert link +
+ (linkUrl.value = el.value)} + placeholder="https://example.com" + /> + +
+
+ )} + + {menu.value === 'image' && ( +
+ Insert image +
+ (imgUrl.value = el.value)} + placeholder="https://image.png" + /> + +
+
+ )} + + {menu.value === 'color' && ( +
+ Text color + (color.value = c)} + horizontal + /> + +
+ )} + + {menu.value === 'size' && ( +
+ Text size + {SIZE_PRESETS.map((s) => ( + + ))} +
+ )} +
+ + {showPreview.value ? ( +
Nothing to preview yet…' + } + /> + ) : ( +