feat: 2026 update (#8)

feat/2026 update
This commit is contained in:
2026-04-24 23:16:16 +02:00
committed by GitHub
71 changed files with 6943 additions and 2245 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Cloudflare D1
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_DATABASE_ID=""
CLOUDFLARE_D1_TOKEN=""
ORIGIN=""
# Better Auth
# For production use 32 characters and generated with high entropy
# https://www.better-auth.com/docs/installation
BETTER_AUTH_SECRET=""
# Cloudflare Turnstile
PUBLIC_TURNSTILE_SITE_KEY=""
TURNSTILE_SECRET_KEY=""

9
.gitignore vendored
View File

@@ -21,4 +21,11 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.cloudflare
.cloudflare
# Cloudflare Types
/worker-configuration.d.ts
.dev.vars*
!.dev.vars.example
.codex

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
/drizzle/

1104
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,6 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "neutral"
},
@@ -10,8 +8,9 @@
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
}

24
drizzle.config.local.ts Normal file
View File

@@ -0,0 +1,24 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig } from 'drizzle-kit';
const localD1Dir = '.wrangler/state/v3/d1/miniflare-D1DatabaseObject';
const localD1File = readdirSync(localD1Dir).find(
(file) => file.endsWith('.sqlite') && file !== 'metadata.sqlite',
);
if (!localD1File) {
throw new Error(`No local D1 sqlite file found in ${localD1Dir}`);
}
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: join(localD1Dir, localD1File),
},
verbose: true,
strict: true,
});

18
drizzle.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'drizzle-kit';
if (!process.env.CLOUDFLARE_ACCOUNT_ID) throw new Error('CLOUDFLARE_ACCOUNT_ID is not set');
if (!process.env.CLOUDFLARE_DATABASE_ID) throw new Error('CLOUDFLARE_DATABASE_ID is not set');
if (!process.env.CLOUDFLARE_D1_TOKEN) throw new Error('CLOUDFLARE_D1_TOKEN is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
databaseId: process.env.CLOUDFLARE_DATABASE_ID,
token: process.env.CLOUDFLARE_D1_TOKEN,
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,5 @@
CREATE TABLE `task` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`priority` integer DEFAULT 1 NOT NULL
);

View File

@@ -0,0 +1,53 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL,
`token` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`user_id` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);

View File

@@ -0,0 +1,28 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer,
`aaguid` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);
--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);
--> statement-breakpoint
ALTER TABLE `user` ADD COLUMN `account_number` text NOT NULL DEFAULT '';
--> statement-breakpoint
ALTER TABLE `user` ADD COLUMN `is_anonymous` integer DEFAULT false;
--> statement-breakpoint
UPDATE `user`
SET `account_number` = substr('0000000000000000' || abs(random()), -16, 16)
WHERE `account_number` = '';
--> statement-breakpoint
CREATE UNIQUE INDEX `user_account_number_unique` ON `user` (`account_number`);

View File

@@ -0,0 +1,18 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer,
`aaguid` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);
--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);

View File

@@ -0,0 +1,14 @@
CREATE TABLE `user_stat_bucket` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`metric` text NOT NULL,
`value` integer NOT NULL,
`bucket_start` integer NOT NULL,
`station_id` integer,
`file_id` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `user_stat_bucket_user_metric_bucket_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`);--> statement-breakpoint
DROP TABLE `task`;

View File

@@ -0,0 +1,9 @@
CREATE TABLE `song_ids` (
`id` text PRIMARY KEY NOT NULL,
`file_id` text NOT NULL,
`spotify_id` text,
`title` text NOT NULL,
`artists` text NOT NULL,
`image` text NOT NULL,
`label` text NOT NULL
);

View File

@@ -0,0 +1,33 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_song_ids` (
`id` text PRIMARY KEY NOT NULL,
`file_id` text NOT NULL,
`spotify_id` text,
`title` text NOT NULL,
`artists` text NOT NULL,
`image` text NOT NULL,
`label` text
);
--> statement-breakpoint
INSERT INTO `__new_song_ids`("id", "file_id", "spotify_id", "title", "artists", "image", "label") SELECT "id", "file_id", "spotify_id", "title", "artists", "image", "label" FROM `song_ids`;--> statement-breakpoint
DROP TABLE `song_ids`;--> statement-breakpoint
ALTER TABLE `__new_song_ids` RENAME TO `song_ids`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `song_ids_file_id_idx` ON `song_ids` (`file_id`);--> statement-breakpoint
CREATE TABLE `__new_user_stat_bucket` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`metric` text NOT NULL,
`value` integer NOT NULL,
`bucket_start` integer NOT NULL,
`station_id` integer DEFAULT 0 NOT NULL,
`file_id` text DEFAULT '' NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_user_stat_bucket`("id", "user_id", "metric", "value", "bucket_start", "station_id", "file_id", "created_at") SELECT "id", "user_id", "metric", "value", "bucket_start", "station_id", "file_id", "created_at" FROM `user_stat_bucket`;--> statement-breakpoint
DROP TABLE `user_stat_bucket`;--> statement-breakpoint
ALTER TABLE `__new_user_stat_bucket` RENAME TO `user_stat_bucket`;--> statement-breakpoint
CREATE INDEX `user_stat_bucket_user_metric_bucket_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_stat_bucket_unique_idx` ON `user_stat_bucket` (`user_id`,`metric`,`bucket_start`,`station_id`,`file_id`);

View File

@@ -0,0 +1 @@
ALTER TABLE `user` ADD `statistics_opt_out` integer DEFAULT false NOT NULL;

View File

@@ -0,0 +1,50 @@
{
"version": "6",
"dialect": "sqlite",
"id": "03052b1a-aca5-4aeb-84b1-03783814ed6a",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"task": {
"name": "task",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,402 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7c532ee7-a504-49a0-91fa-c0206fa1f3b0",
"prevId": "03052b1a-aca5-4aeb-84b1-03783814ed6a",
"tables": {
"task": {
"name": "task",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,540 @@
{
"version": "6",
"dialect": "sqlite",
"id": "773dd59c-33d4-4b87-8541-dda2e9151482",
"prevId": "7c532ee7-a504-49a0-91fa-c0206fa1f3b0",
"tables": {
"task": {
"name": "task",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,599 @@
{
"version": "6",
"dialect": "sqlite",
"id": "d97ddeeb-3d7a-46a2-9a50-5d90fc51516e",
"prevId": "773dd59c-33d4-4b87-8541-dda2e9151482",
"tables": {
"user_stat_bucket": {
"name": "user_stat_bucket",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"station_id": {
"name": "station_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_stat_bucket_user_metric_bucket_idx": {
"name": "user_stat_bucket_user_metric_bucket_idx",
"columns": [
"user_id",
"metric",
"bucket_start"
],
"isUnique": false
}
},
"foreignKeys": {
"user_stat_bucket_user_id_user_id_fk": {
"name": "user_stat_bucket_user_id_user_id_fk",
"tableFrom": "user_stat_bucket",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,658 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9eba4b9d-41cd-4908-9356-2bb7075467b9",
"prevId": "d97ddeeb-3d7a-46a2-9a50-5d90fc51516e",
"tables": {
"song_ids": {
"name": "song_ids",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"spotify_id": {
"name": "spotify_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"artists": {
"name": "artists",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stat_bucket": {
"name": "user_stat_bucket",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"station_id": {
"name": "station_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_stat_bucket_user_metric_bucket_idx": {
"name": "user_stat_bucket_user_metric_bucket_idx",
"columns": [
"user_id",
"metric",
"bucket_start"
],
"isUnique": false
}
},
"foreignKeys": {
"user_stat_bucket_user_id_user_id_fk": {
"name": "user_stat_bucket_user_id_user_id_fk",
"tableFrom": "user_stat_bucket",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,679 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9e75c298-5f62-44f0-9f1a-40481e7df553",
"prevId": "9eba4b9d-41cd-4908-9356-2bb7075467b9",
"tables": {
"song_ids": {
"name": "song_ids",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"spotify_id": {
"name": "spotify_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"artists": {
"name": "artists",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"song_ids_file_id_idx": {
"name": "song_ids_file_id_idx",
"columns": [
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stat_bucket": {
"name": "user_stat_bucket",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"station_id": {
"name": "station_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_stat_bucket_user_metric_bucket_idx": {
"name": "user_stat_bucket_user_metric_bucket_idx",
"columns": [
"user_id",
"metric",
"bucket_start"
],
"isUnique": false
},
"user_stat_bucket_unique_idx": {
"name": "user_stat_bucket_unique_idx",
"columns": [
"user_id",
"metric",
"bucket_start",
"station_id",
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_stat_bucket_user_id_user_id_fk": {
"name": "user_stat_bucket_user_id_user_id_fk",
"tableFrom": "user_stat_bucket",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,687 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ec11112d-e50d-4aff-9914-e3d270cc2f5a",
"prevId": "9e75c298-5f62-44f0-9f1a-40481e7df553",
"tables": {
"song_ids": {
"name": "song_ids",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"spotify_id": {
"name": "spotify_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"artists": {
"name": "artists",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"song_ids_file_id_idx": {
"name": "song_ids_file_id_idx",
"columns": [
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_stat_bucket": {
"name": "user_stat_bucket",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metric": {
"name": "metric",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket_start": {
"name": "bucket_start",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"station_id": {
"name": "station_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"file_id": {
"name": "file_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_stat_bucket_user_metric_bucket_idx": {
"name": "user_stat_bucket_user_metric_bucket_idx",
"columns": [
"user_id",
"metric",
"bucket_start"
],
"isUnique": false
},
"user_stat_bucket_unique_idx": {
"name": "user_stat_bucket_unique_idx",
"columns": [
"user_id",
"metric",
"bucket_start",
"station_id",
"file_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_stat_bucket_user_id_user_id_fk": {
"name": "user_stat_bucket_user_id_user_id_fk",
"tableFrom": "user_stat_bucket",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"passkey": {
"name": "passkey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transports": {
"name": "transports",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"aaguid": {
"name": "aaguid",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"passkey_userId_idx": {
"name": "passkey_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"passkey_credentialID_idx": {
"name": "passkey_credentialID_idx",
"columns": [
"credential_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"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": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"is_anonymous": {
"name": "is_anonymous",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"account_number": {
"name": "account_number",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statistics_opt_out": {
"name": "statistics_opt_out",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
},
"user_account_number_unique": {
"name": "user_account_number_unique",
"columns": [
"account_number"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,55 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1775242146068,
"tag": "0000_wooden_magdalene",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1775242876895,
"tag": "0001_curved_sunspot",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1775246437951,
"tag": "0002_account_number_auth",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1775423880350,
"tag": "0003_stiff_captain_universe",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1775425092281,
"tag": "0004_abandoned_captain_stacy",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1777011840981,
"tag": "0005_chubby_master_mold",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1777041984571,
"tag": "0006_known_avengers",
"breakpoints": true
}
]
}

View File

@@ -6,38 +6,52 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"preview": "bun run build && wrangler dev",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"ui:add": "npx shadcn-svelte@next add",
"format": "prettier --write ."
"format": "prettier --write .",
"gen": "wrangler types",
"deploy": "bun run build && wrangler deploy",
"cf-typegen": "wrangler types",
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate:prod": "drizzle-kit migrate",
"db:migrate:dev": "bun wrangler d1 migrations apply lofi_db --local",
"db:studio": "drizzle-kit studio",
"db:studio:dev": "drizzle-kit studio --config drizzle.config.local.ts",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
},
"devDependencies": {
"@better-auth/cli": "~1.4.21",
"@better-auth/passkey": "~1.4.21",
"@cloudflare/workers-types": "^4.20250517.0",
"@lucide/svelte": "^0.492.0",
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/adapter-cloudflare": "^7.0.3",
"@sveltejs/adapter-cloudflare": "^7.2.6",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^24",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.3.19",
"better-auth": "~1.4.21",
"bits-ui": "^1.4.7",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.2",
"mode-watcher": "^1.0.5",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28",
"svelte-turnstile": "^0.11.0",
"tailwind-merge": "^3.2.0",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"wrangler": "^4.15.2"
"wrangler": "^4.63.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"dependencies": {
"mode-watcher": "^1.0.5"
}
"packageManager": "bun@1.3.5"
}

21
src/app.d.ts vendored
View File

@@ -1,14 +1,29 @@
import type { auth } from '$lib/server/auth';
import { createAuth } from '$lib/server/auth';
type Session = typeof auth.$Infer.Session.session;
type User = typeof auth.$Infer.Session.user;
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
interface Platform {
caches: CacheStorage & { default: Cache }
}
caches: CacheStorage & { default: Cache };
env: Env;
ctx: ExecutionContext;
caches: CacheStorage;
cf?: IncomingRequestCfProperties;
}
interface Locals {
user?: User;
session?: Session;
auth: ReturnType<typeof createAuth>;
}
}
}

23
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { createAuth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
if (!event.platform?.env?.DB)
throw new Error('D1 binding "DB" not found - are you running with wrangler?');
event.locals.auth = createAuth(event.platform.env.DB, event.url.origin);
const { auth } = event.locals;
const session = await auth.api.getSession({ headers: event.request.headers });
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
}
return svelteKitHandler({ event, resolve, auth, building });
};
export const handle: Handle = handleBetterAuth;

30
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { BetterAuthClientPlugin } from 'better-auth/client';
import { anonymousClient, inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/svelte';
import { passkeyClient } from '@better-auth/passkey/client';
import type { auth } from '$lib/server/auth';
const accountNumberClient = {
id: 'account-number',
getActions: ($fetch) => ({
createAccount: async (name?: string, turnstileToken?: string) =>
$fetch('/create-account', {
method: 'POST',
body: {
name,
turnstileToken,
},
}),
signInAccountNumber: async (accountNumber: string) =>
$fetch('/sign-in/account-number', {
method: 'POST',
body: {
accountNumber,
},
}),
}),
} satisfies BetterAuthClientPlugin;
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>(), anonymousClient(), passkeyClient(), accountNumberClient],
});

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import AuthDialog from './auth-dialog.svelte';
</script>
<div
class="flex absolute top-0 right-0 items-center p-4 bg-foreground/10 backdrop-blur-lg rounded-bl-xl shadow-lg space-x-2"
>
<AuthDialog />
</div>

View File

@@ -0,0 +1,524 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { authClient } from '$lib';
import LogIn from '@lucide/svelte/icons/log-in';
import Settings from '@lucide/svelte/icons/settings-2';
import { Button } from '../ui/button';
import Label from '../ui/label/label.svelte';
import Input from '../ui/input/input.svelte';
import Key from '@lucide/svelte/icons/key';
import Trash2 from '@lucide/svelte/icons/trash-2';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Fingerprint from '@lucide/svelte/icons/fingerprint';
import Plus from '@lucide/svelte/icons/plus';
import Loader2 from '@lucide/svelte/icons/loader-2';
import type { Passkey } from '@better-auth/passkey';
import { Turnstile } from 'svelte-turnstile';
import { dev } from '$app/environment';
import { env as publicEnv } from '$env/dynamic/public';
import Switch from '../ui/switch/switch.svelte';
const session = authClient.useSession();
let open = $state(false);
let accountNumber = $state('');
let name = $state('');
let authMessage = $state('');
let busyAction = $state<string | null>(null);
let passkeyMessage = $state('');
const user = $derived($session.data?.user);
let passkeys = $state<Passkey[]>([]);
let loadedPasskeysForUserId = $state<string | null>(null);
let authScreen = $state<'login' | 'create'>('login');
let passkeyName = $state('');
let statisticsMessage = $state('');
let turnstileToken = $state('');
let resetTurnstile = $state<(() => void) | undefined>();
let createFormElement = $state<HTMLDivElement>();
const turnstileSiteKey = $derived(
dev ? '1x00000000000000000000AA' : (publicEnv.PUBLIC_TURNSTILE_SITE_KEY ?? '')
);
const loadPasskeys = async () => {
if (!user) {
passkeys = [];
loadedPasskeysForUserId = null;
return;
}
const result = await authClient.passkey.listUserPasskeys();
passkeys = result.data ?? [];
loadedPasskeysForUserId = user.id;
};
$effect(() => {
if (!user) {
passkeys = [];
loadedPasskeysForUserId = null;
return;
}
if (loadedPasskeysForUserId !== user.id) {
loadPasskeys();
}
});
const runAuthAction = async (
action: string,
request: () => Promise<{ error?: { message?: string | null } | null }>,
fallbackMessage: string,
onSuccess?: () => void | Promise<void>
) => {
busyAction = action;
authMessage = '';
const result = await request();
busyAction = null;
if (result.error) {
authMessage = result.error.message || fallbackMessage;
return;
}
await onSuccess?.();
open = false;
};
const signInWithAccountNumber = () =>
runAuthAction(
'account-number',
() => authClient.signInAccountNumber(accountNumber.replace(/\D/g, '')),
'Account number sign-in failed',
() => {
accountNumber = '';
}
);
const clearTurnstile = () => {
turnstileToken = '';
resetTurnstile?.();
};
const handleTurnstileCallback = (
event: CustomEvent<{ token: string; preClearanceObtained: boolean }>
) => {
turnstileToken = event.detail.token;
authMessage = '';
};
const handleTurnstileError = () => {
clearTurnstile();
authMessage = 'Turnstile verification failed. Please try again.';
};
const handleTurnstileExpired = () => {
clearTurnstile();
authMessage = 'Turnstile check expired. Please try again.';
};
const getTurnstileToken = () => {
const formToken = createFormElement
?.querySelector<HTMLInputElement>('input[name="cf-turnstile-response"]')
?.value
.trim();
return turnstileToken || formToken || '';
};
const createAccount = async () => {
const token = getTurnstileToken();
if (!token) {
authMessage = 'Please complete the Turnstile check before creating an account.';
return;
}
await runAuthAction(
'create-account',
() => authClient.createAccount(name, token),
'Account creation failed',
async () => {
await session.get().refetch();
name = '';
authScreen = 'login';
clearTurnstile();
}
);
if (busyAction !== 'create-account') {
clearTurnstile();
}
};
const signInWithPasskey = () =>
runAuthAction(
'passkey-sign-in',
() =>
authClient.signIn.passkey({
autoFill: true,
}),
'Passkey sign-in failed'
);
const signOut = () => runAuthAction('sign-out', () => authClient.signOut(), 'Sign out failed');
const updateStatisticsOptOut = async (statisticsOptOut: boolean) => {
busyAction = 'statistics-opt-out';
statisticsMessage = '';
const response = await fetch('/api/account/statistics-opt-out', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ statisticsOptOut }),
});
busyAction = null;
if (!response.ok) {
statisticsMessage = 'Failed to update statistics preference';
return;
}
await session.get().refetch();
statisticsMessage = statisticsOptOut
? 'Statistics collection is turned off.'
: 'Statistics collection is turned on.';
};
const addPasskey = async () => {
if (!passkeyName) {
passkeyMessage = 'Please enter a name for your passkey';
return;
}
busyAction = 'add-passkey';
passkeyMessage = '';
const result = await authClient.passkey.addPasskey({
name: passkeyName,
authenticatorAttachment: 'platform',
});
busyAction = null;
if (result.error) {
passkeyMessage = result.error.message || 'Failed to add passkey';
return;
}
await loadPasskeys();
passkeyMessage = 'Passkey added successfully';
passkeyName = '';
};
const deletePasskey = async (id: string) => {
busyAction = `delete-passkey-${id}`;
passkeyMessage = '';
const result = await authClient.passkey.deletePasskey({ id });
busyAction = null;
if (result.error) {
passkeyMessage = result.error.message || 'Failed to delete passkey';
return;
}
await loadPasskeys();
passkeyMessage = 'Passkey removed';
};
</script>
<Dialog.Root bind:open>
<Dialog.Trigger>
<Button class="group relative overflow-hidden gap-2 font-medium tracking-wide">
<span class="relative z-10 flex items-center gap-2">
{#if user}
<Settings class="size-4 transition-transform duration-300 group-hover:rotate-45" />
Account
{:else}
<LogIn class="size-4 transition-transform duration-300 group-hover:translate-x-0.5" />
Sign in
{/if}
</span>
</Button>
</Dialog.Trigger>
<Dialog.Content
class="!p-0 !gap-0 overflow-hidden max-w-md w-[95vw]"
style="--text-color: rgba(255, 255, 255, 0.92); --text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); --foreground: 0 0% 98%; --muted-foreground: 0 0% 72%;"
>
<Dialog.Header class="p-5 border-b border-white/[0.06]">
<Dialog.Title class="!text-lg !font-semibold tracking-tight">
{user ? 'Account Settings' : 'Welcome Back'}
</Dialog.Title>
<p class="text-sm text-white/50 mt-1">
{user ? 'Manage your account and security' : 'Sign in to sync your preferences'}
</p>
</Dialog.Header>
<div class="p-6 pt-2 space-y-5">
{#if user}
<div
class="flex items-center gap-4 p-4 rounded-xl bg-white/[0.04] border border-white/[0.08]"
>
<div
class="flex-shrink-0 w-12 h-12 rounded-full bg-white/[0.1] flex items-center justify-center"
>
<span class="text-lg font-bold text-white">{user.name?.[0]?.toUpperCase() || '?'}</span>
</div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-white truncate">{user.name}</p>
<p class="text-sm text-white/50 font-mono tracking-tight">#{user.accountNumber}</p>
</div>
</div>
<div class="space-y-3 p-4 rounded-xl bg-white/[0.03] border border-white/[0.06]">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0 space-y-1">
<h3 class="text-sm font-medium text-white/80">Listening statistics</h3>
<p class="text-xs leading-relaxed text-white/45">
{user.statisticsOptOut
? 'New listening activity is not being collected for your account.'
: 'Collect listening activity to power your account statistics.'}
</p>
</div>
<Switch
id="statisticsCollection"
checked={!user.statisticsOptOut}
onCheckedChange={(checked) => updateStatisticsOptOut(!checked)}
disabled={busyAction === 'statistics-opt-out'}
/>
</div>
{#if statisticsMessage}
<p class="text-xs text-white/60">{statisticsMessage}</p>
{/if}
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium text-white/80">Passkeys</h3>
<div class="space-y-2">
{#each passkeys as passkey (passkey.id)}
<div
class="group flex items-center gap-3 p-3 rounded-lg bg-white/[0.03] border border-white/[0.06] transition-all duration-200 hover:bg-white/[0.06] hover:border-white/[0.1]"
>
<div
class="flex-shrink-0 w-9 h-9 rounded-lg bg-white/[0.06] flex items-center justify-center"
>
<Key class="size-4 text-white/70" />
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-white/90 truncate">{passkey.name}</p>
<p class="text-xs text-white/40">
{new Date(passkey.createdAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
class="!size-8 opacity-0 group-hover:opacity-100 transition-opacity duration-200 !text-red-400/70 hover:!text-red-400 hover:!bg-red-500/10"
aria-label="Delete passkey"
onclick={() => deletePasskey(passkey.id)}
disabled={busyAction === `delete-passkey-${passkey.id}`}
>
{#if busyAction === `delete-passkey-${passkey.id}`}
<Loader2 class="size-4 animate-spin" />
{:else}
<Trash2 class="size-3.5" />
{/if}
</Button>
</div>
{:else}
<div
class="flex items-center gap-3 p-4 rounded-lg bg-white/[0.02] border border-dashed border-white/[0.1] text-white/40"
>
<Fingerprint class="size-5 !text-white/40" />
<p class="text-sm !text-white/40">No passkeys added yet</p>
</div>
{/each}
</div>
</div>
<div class="space-y-2">
<div class="flex gap-2">
<div class="flex-1">
<Input
type="text"
id="passkeyName"
bind:value={passkeyName}
placeholder="Device name"
class="!h-10"
/>
</div>
<Button
onclick={addPasskey}
disabled={busyAction === 'add-passkey'}
class="!h-10 px-4 !text-white/80 hover:!text-white"
>
{#if busyAction === 'add-passkey'}
<Loader2 class="size-4 animate-spin" />
{:else}
<Plus class="size-4" />
{/if}
</Button>
</div>
{#if passkeyMessage}
<p
class="text-xs px-3 py-2 rounded-lg {passkeyMessage.includes('success') ||
passkeyMessage.includes('removed') ||
passkeyMessage.includes('added')
? 'bg-white/[0.06] text-white/70 border border-white/[0.08]'
: 'bg-red-500/10 text-red-400 border border-red-500/20'}"
>
{passkeyMessage}
</p>
{/if}
</div>
<div class="pt-3 border-t border-white/[0.06]">
<Button
variant="ghost"
onclick={signOut}
disabled={busyAction === 'sign-out'}
class="w-full justify-center gap-2 !text-white/60 hover:!text-white hover:bg-white/[0.06]"
>
{#if busyAction === 'sign-out'}
<Loader2 class="size-4 animate-spin" />
{:else}
<LogIn class="size-4 rotate-180" />
{/if}
Sign out
</Button>
</div>
{:else if authScreen === 'login'}
<div class="space-y-4" bind:this={createFormElement}>
<div class="space-y-2">
<Label for="accountNumber" class="text-white/70">Account Number</Label>
<Input
type="text"
id="accountNumber"
bind:value={accountNumber}
placeholder="Enter your account number"
autocomplete="one-time-code webauthn"
/>
</div>
<div class="flex gap-2">
<Button
type="button"
onclick={signInWithAccountNumber}
disabled={busyAction === 'account-number' || !accountNumber}
class="flex-1 disabled:opacity-50"
>
{#if busyAction === 'account-number'}
<Loader2 class="size-4 animate-spin" />
Signing in...
{:else}
Sign in
{/if}
</Button>
<Button
type="button"
size="icon"
onclick={signInWithPasskey}
disabled={busyAction === 'passkey-sign-in'}
class="!w-auto px-3"
title="Sign in with passkey"
>
{#if busyAction === 'passkey-sign-in'}
<Loader2 class="size-4 animate-spin" />
{:else}
<Fingerprint class="size-4" />
{/if}
</Button>
</div>
</div>
<div class="relative flex items-center gap-3 py-2">
<div
class="flex-1 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
></div>
<span class="text-xs text-white/30 uppercase tracking-wider font-medium">or</span>
<div
class="flex-1 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
></div>
</div>
<Button
type="button"
onclick={() => (authScreen = 'create')}
disabled={busyAction === 'create-account'}
variant="ghost"
class="w-full justify-center text-white/60 hover:text-white hover:bg-white/[0.06]"
>
Create new account
</Button>
{#if authMessage}
<p
class="text-xs text-red-400 bg-red-500/10 rounded-lg px-3 py-2 border border-red-500/20"
>
{authMessage}
</p>
{/if}
{:else if authScreen === 'create'}
<div class="space-y-4">
<Button
type="button"
variant="ghost"
onclick={() => (authScreen = 'login')}
disabled={busyAction === 'create-account'}
class="!p-0 h-auto text-white/50 hover:text-white hover:bg-transparent"
>
<ArrowLeft class="size-4 mr-1" />
Back to sign in
</Button>
<div class="space-y-2">
<Label for="name" class="text-white/70">Your Name</Label>
<Input
type="text"
id="name"
bind:value={name}
placeholder="What should we call you?"
autocomplete="name webauthn"
/>
</div>
<Turnstile
siteKey={turnstileSiteKey}
bind:reset={resetTurnstile}
on:callback={handleTurnstileCallback}
on:error={handleTurnstileError}
on:expired={handleTurnstileExpired}
/>
<Button
type="button"
onclick={createAccount}
disabled={busyAction === 'create-account' || !name}
class="w-full disabled:opacity-50"
>
{#if busyAction === 'create-account'}
<Loader2 class="size-4 animate-spin" />
Creating account...
{:else}
Create Account
{/if}
</Button>
{#if authMessage}
<p
class="text-xs text-red-400 bg-red-500/10 rounded-lg px-3 py-2 border border-red-500/20"
>
{authMessage}
</p>
{/if}
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>

View File

@@ -4,11 +4,10 @@
import MusicPlayer from '@/components/app/now-playing.svelte';
import Sounds from '@/components/app/atmospheres.svelte';
import StationDropdown from '@/components/app/station-dropdown.svelte';
import Feedback from '@/components/app/feedback.svelte';
</script>
<div
class="fixed bottom-5 left-2 right-2 z-50 flex flex-col sm:flex-row items-center p-4 bg-white/10 backdrop-blur-lg rounded-xl shadow-lg"
class="fixed bottom-5 left-2 right-2 z-50 flex flex-col sm:flex-row items-center p-4 bg-foreground/10 backdrop-blur-lg rounded-xl shadow-lg"
>
<MusicPlayer />
@@ -17,7 +16,6 @@
<StationDropdown />
<BgDropdown />
<Sounds />
<Feedback />
<Disclaimer />
</div>
</div>

View File

@@ -6,6 +6,12 @@
import Window from '../ui/window/window.svelte';
import TodoList from './todo-list.svelte';
import Twentytwentytwenty from './twentytwentytwenty.svelte';
import Pomodoro from './pomodoro.svelte';
import Stats from './stats.svelte';
import { authClient } from '$lib';
const session = authClient.useSession();
const user = $derived($session.data?.user);
// svelte-ignore non_reactive_update
let audioElement: HTMLAudioElement;
@@ -160,6 +166,34 @@
}
});
onMount(() => {
const listenInterval = setInterval(async () => {
if (
!appState.isPlaying ||
!appState.currentSong ||
!appState.currentStation ||
!user ||
user?.statisticsOptOut ||
!audioElement ||
audioElement.paused
) {
return;
}
await fetch('/api/listen', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
fileId: appState.currentSong.fileId,
stationId: appState.currentStation,
seconds: 30,
}),
});
}, 30_000);
return () => clearInterval(listenInterval);
})
$effect(() => {
if (!audioElement) return;
togglePlayback(appState.isPlaying);
@@ -260,4 +294,28 @@
show={appState.show202020}
>
<Twentytwentytwenty></Twentytwentytwenty>
</Window>
</Window>
<Window
title="Pomodoro Timer"
showTitleBar={true}
showCloseButton={true}
width={320}
height={250}
onClose={() => appState.showPomodoro = false}
show={appState.showPomodoro}
>
<Pomodoro></Pomodoro>
</Window>
<Window
title="Stats"
showTitleBar={true}
showCloseButton={true}
width={500}
height={400}
onClose={() => appState.showStats = false}
show={appState.showStats}
>
<Stats></Stats>
</Window>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js';
import MessageSquare from '@lucide/svelte/icons/message-square';
import { Button } from '../ui/button';
import { toast } from 'svelte-sonner';
let feedback = $state('');
let open = $state(false);
</script>
<Dialog.Root bind:open>
<Dialog.Trigger><Button size="icon"><MessageSquare /></Button></Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send Feedback</Dialog.Title>
</Dialog.Header>
<p>
Have suggestions or found a bug? Let me know!
</p>
<form class="mt-4 space-y-4" onsubmit={(e) => {
e.preventDefault();
fetch('https://cors.notesnook.com/https://echospace.dev/api/feedback/cma9juarn0000m40sko0qqt2t', {
method: 'POST',
body: JSON.stringify({
message: feedback,
})
}).then(() => {
open = false;
feedback = '';
toast('Thanks for your feedback!')
})
}}>
<div class="grid w-full items-center gap-1.5">
<textarea id="feedback" class="p-2 rounded-md bg-white/10 border border-white/20" rows="4" bind:value={feedback}></textarea>
</div>
<Button type="submit">Submit</Button>
</form>
</Dialog.Content>
</Dialog.Root>

View File

@@ -3,10 +3,12 @@
import Check from '@lucide/svelte/icons/check';
import Button from '../ui/button/button.svelte';
import Binoculars from '@lucide/svelte/icons/binoculars';
import Clock from '@lucide/svelte/icons/clock';
import ChartNoAxesColumn from '@lucide/svelte/icons/chart-no-axes-column';
</script>
<div
class="absolute left-2 top-1/2 transform -translate-y-1/2 p-4 bg-white/10 backdrop-blur-lg rounded-xl shadow-lg flex flex-col space-y-2"
class="absolute left-2 top-1/2 transform -translate-y-1/2 p-4 bg-foreground/10 backdrop-blur-lg rounded-xl shadow-lg flex flex-col space-y-2"
>
<Button size="icon" onclick={() => (appState.showTodoList = !appState.showTodoList)}>
<Check class="size-4" />
@@ -17,4 +19,16 @@
>
<Binoculars class="size-4" />
</Button>
<Button
size="icon"
onclick={() => (appState.showPomodoro = !appState.showPomodoro)}
>
<Clock class="size-4" />
</Button>
<Button
size="icon"
onclick={() => (appState.showStats = !appState.showStats)}
>
<ChartNoAxesColumn class="size-4" />
</Button>
</div>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import Button from '@/components/ui/button/button.svelte';
import { state as appState } from '@/state.svelte';
import { onMount } from 'svelte';
let timeLeft = $state(0);
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let startSoundElement: HTMLAudioElement;
let endSoundElement: HTMLAudioElement;
let minutes = $derived(Math.floor(timeLeft / 60));
let seconds = $derived(timeLeft % 60);
onMount(() => {
const defaultFirst = 20 * 60;
const defaultSecond = 5 * 60;
if (!appState.pomodoroTimer) appState.pomodoroTimer = defaultFirst;
if (!appState.pomodoroBreakTimer) appState.pomodoroBreakTimer = defaultSecond;
timeLeft = appState.pomodoroTimer;
});
function playSound(element: HTMLAudioElement) {
if (!element) return;
element.currentTime = 0;
const playPromise = element.play();
if (playPromise) {
playPromise.catch((error) => {
console.error('Audio play error:', error);
if (error.name === 'NotAllowedError') {
console.warn('Audio playback blocked by browser. User interaction required.');
}
});
}
}
function startCountdown() {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
intervalHandle = setInterval(() => {
if (timeLeft > 0) {
timeLeft--;
} else {
if (appState.pomodoroWorkPhase) {
appState.pomodoroWorkPhase = false;
timeLeft = appState.pomodoroBreakTimer;
playSound(startSoundElement);
} else {
appState.pomodoroWorkPhase = true;
timeLeft = appState.pomodoroTimer;
playSound(endSoundElement);
appState.isPomodoroActive = false;
}
}
}, 1000);
}
$effect(() => {
if (appState.isPomodoroActive) {
startCountdown();
} else if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
}
return () => {
if (intervalHandle) {
clearInterval(intervalHandle);
intervalHandle = null;
appState.isPomodoroActive = false;
}
};
});
function startTimer() {
reset();
timeLeft = appState.pomodoroTimer;
appState.pomodoroWorkPhase = true;
appState.isPomodoroActive = true;
}
function stopTimer() {
reset();
appState.isPomodoroActive = false;
}
function reset() {
appState.pomodoroTimer = 20 * 60;
appState.pomodoroBreakTimer = 5 * 60;
}
</script>
<audio
bind:this={startSoundElement}
src="https://lofi-cdn.srizan.dev/assets/202020/start.mp3"
preload="auto"
></audio>
<audio
bind:this={endSoundElement}
src="https://lofi-cdn.srizan.dev/assets/202020/done.mp3"
preload="auto"
></audio>
<div class="flex flex-col p-4">
<div class="mb-6">
<div class="text-2xl font-bold">
{appState.pomodoroWorkPhase ? 'Work Time' : 'Break Time'}
</div>
<div class="text-3xl font-bold mt-2">
{minutes.toString().padStart(2, '0')}:{seconds.toString().padStart(2, '0')}
</div>
</div>
<div class="flex gap-2">
{#if appState.isPomodoroActive}
<Button variant="destructive" onclick={stopTimer}>Stop</Button>
{:else}
<Button onclick={startTimer}>Start</Button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import { onMount } from 'svelte';
import { state as appState } from '@/state.svelte';
import Radio from '@lucide/svelte/icons/radio';
import ListMusic from '@lucide/svelte/icons/list-music';
import { authClient } from '@/auth-client';
const session = authClient.useSession();
type TopSong = {
fileId: string;
title: string | null;
artists: string | null;
image: string | null;
seconds: number;
};
type TopStation = {
stationId: number | null;
seconds: number;
};
type StatsResponse = {
totalSeconds: number;
todaySeconds: number;
topSongs: TopSong[];
topStations: TopStation[];
};
let stats = $state<StatsResponse | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
let activeTab = $state<'stations' | 'songs'>('stations');
const formatDuration = (seconds: number) => {
const totalMinutes = Math.floor(seconds / 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${minutes}m`;
if (minutes === 0) return `${hours}h`;
return `${hours}h ${minutes}m`;
};
const stationName = (stationId: number | null) => {
const station = appState.stations.find((item) => item.id === stationId);
return station?.name ?? 'Unknown station';
};
const maxStationSeconds = $derived.by(() => {
if (!stats) return 1;
return Math.max(...stats.topStations.map((s) => s.seconds), 1);
});
const maxSongSeconds = $derived.by(() => {
if (!stats) return 1;
return Math.max(...stats.topSongs.map((s) => s.seconds), 1);
});
onMount(async () => {
try {
const response = await fetch('/api/stats');
if (!response.ok) {
error =
response.status === 401
? 'Sign in to see your listening stats.'
: 'Could not load stats.';
return;
}
stats = await response.json();
} catch {
if (session) {
error = 'Could not load stats. Please try again later.';
} else {
error = 'Sign in to see your listening stats.';
}
} finally {
isLoading = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col p-5 text-foreground">
{#if isLoading}
<div class="flex flex-1 flex-col items-center justify-center gap-3 text-foreground/40">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-foreground/15 border-t-foreground/60"></div>
<span class="text-xs tracking-wide">Loading stats</span>
</div>
{:else if error}
<div class="flex flex-1 flex-col items-center justify-center text-center">
<p class="text-sm text-foreground/50">{error}</p>
</div>
{:else if stats}
<div class="mb-5 shrink-0">
<p class="mb-2 text-[10px] font-medium uppercase tracking-[0.2em] text-foreground/40">
Listening Stats
</p>
<div class="flex items-baseline gap-5">
<div>
<span class="text-3xl">{formatDuration(stats.todaySeconds)}</span>
<span class="ml-1.5 text-[10px] font-medium uppercase tracking-wider text-foreground/40">Today</span>
</div>
<div class="h-3 w-px bg-white/10"></div>
<div>
<span class="text-3xl">{formatDuration(stats.totalSeconds)}</span>
<span class="ml-1.5 text-[10px] font-medium uppercase tracking-wider text-foreground/40">All time</span>
</div>
</div>
</div>
<div class="mb-3 flex shrink-0 items-center justify-between">
<div class="flex rounded-lg border border-white/[0.06] bg-white/[0.03] p-0.5">
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-[11px] font-medium transition-all duration-200 {activeTab === 'stations' ? 'bg-white/10 text-foreground shadow-sm' : 'text-foreground/40 hover:text-foreground/70'}"
onclick={() => (activeTab = 'stations')}
>
<Radio class="size-3" />
Stations
</button>
<button
class="flex items-center gap-1.5 rounded-md px-3 py-1 text-[11px] font-medium transition-all duration-200 {activeTab === 'songs' ? 'bg-white/10 text-foreground shadow-sm' : 'text-foreground/40 hover:text-foreground/70'}"
onclick={() => (activeTab = 'songs')}
>
<ListMusic class="size-3" />
Songs
</button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto custom-scrollbar">
{#if activeTab === 'stations'}
{#each stats.topStations as station, i}
<div class="flex items-center gap-3 border-b border-white/[0.04] py-2.5 last:border-0">
<span class="w-4 text-right text-[10px] font-mono text-foreground/25">{i + 1}</span>
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center justify-between gap-3">
<span class="truncate text-sm text-foreground/90">
{stationName(station.stationId)}
</span>
<span class="shrink-0 text-xs font-mono text-foreground/40">
{formatDuration(station.seconds)}
</span>
</div>
<div class="h-[2px] overflow-hidden rounded-full bg-white/[0.06]">
<div
class="h-full rounded-full bg-white/20 transition-all duration-700 ease-out"
style="width: {(station.seconds / maxStationSeconds) * 100}%"
></div>
</div>
</div>
</div>
{:else}
<div class="flex h-32 items-center justify-center">
<p class="text-xs text-foreground/30">Station stats will appear after you listen for a bit.</p>
</div>
{/each}
{:else}
{#each stats.topSongs as song, i}
<div class="flex items-center gap-3 border-b border-white/[0.04] py-2.5 last:border-0">
<span class="w-4 text-right text-[10px] font-mono text-foreground/25">{i + 1}</span>
{#if song.image}
<img src={song.image} alt="" class="size-7 shrink-0 rounded-sm object-cover opacity-80" />
{:else}
<div class="size-7 shrink-0 rounded-sm bg-white/[0.06]"></div>
{/if}
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center justify-between gap-3">
<span class="truncate text-sm text-foreground/90">
{song.title ?? song.fileId}
</span>
<span class="shrink-0 text-xs font-mono text-foreground/40">
{formatDuration(song.seconds)}
</span>
</div>
<div class="h-[2px] overflow-hidden rounded-full bg-white/[0.06]">
<div
class="h-full rounded-full bg-white/20 transition-all duration-700 ease-out"
style="width: {(song.seconds / maxSongSeconds) * 100}%"
></div>
</div>
</div>
</div>
{:else}
<div class="flex h-32 items-center justify-center">
<p class="text-xs text-foreground/30">No top songs yet.</p>
</div>
{/each}
{/if}
</div>
{/if}
</div>

View File

@@ -35,22 +35,22 @@
<input
bind:value={newTodoText}
placeholder="Add a new task..."
class="flex-1 pl-3 rounded-lg bg-white bg-opacity-10 border border-white border-opacity-20 text-white placeholder-white placeholder-opacity-60 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-40"
class="flex-1 pl-3 rounded-lg bg-white bg-opacity-10 border border-white border-opacity-20 text-foreground placeholder:text-foreground placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-40"
onkeypress={(e) => e.key === 'Enter' && addTodo()}
/>
<Button onclick={addTodo} class="bg-white bg-opacity-20 hover:bg-opacity-30 text-white px-4 py-2 rounded-lg" size="icon"><Plus /></Button>
<Button onclick={addTodo} class="bg-white bg-opacity-20 hover:bg-opacity-30 text-foreground px-4 py-2 rounded-lg" size="icon"><Plus /></Button>
</div>
{#if appState.todoList.length === 0}
<p class="text-white text-opacity-70 text-center py-4">No tasks yet. Add one above!</p>
<p class="text-foreground text-opacity-70 text-center py-4">No tasks yet. Add one above!</p>
{:else}
<ul class="space-y-2">
{#each appState.todoList as todo, index}
<li class="flex items-center justify-between p-3 bg-white bg-opacity-10 rounded-lg group hover:bg-opacity-15 transition-all">
<span class="text-white">{todo}</span>
<span class="text-foreground">{todo}</span>
<button
onclick={() => removeTodo(index)}
class="text-white hover:text-red-300 focus:outline-none"
class="text-foreground hover:text-red-300 focus:outline-none"
aria-label="Remove todo"
>
<X class="size-4" />

View File

@@ -1,17 +1,17 @@
<script lang="ts">
import { Slider } from "$lib/components/ui/slider";
import * as Popover from "$lib/components/ui/popover";
import { state as appState } from "@/state.svelte";
import VolumeZero from "@lucide/svelte/icons/volume";
import VolumeOne from "@lucide/svelte/icons/volume-1";
import VolumeTwo from "@lucide/svelte/icons/volume-2";
import VolumeX from "@lucide/svelte/icons/volume-x";
import { Button } from "../ui/button";
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { state as appState } from '@/state.svelte';
import VolumeZero from '@lucide/svelte/icons/volume';
import VolumeOne from '@lucide/svelte/icons/volume-1';
import VolumeTwo from '@lucide/svelte/icons/volume-2';
import VolumeX from '@lucide/svelte/icons/volume-x';
import { Button } from '../ui/button';
let value = $state(appState.volume);
$effect(() => {
appState.volume = value;
window.localStorage.setItem("volume", value.toString());
window.localStorage.setItem('volume', value.toString());
});
</script>
@@ -20,16 +20,16 @@
<Button size="icon" class="size-10">
{#if value === 0}
<VolumeX />
{:else if value > 0 && value <= 0.4}
{:else if value > 0 && value <= 0.4}
<VolumeZero />
{:else if value > 0.4 && value <= 0.8}
{:else if value > 0.4 && value <= 0.8}
<VolumeOne />
{:else}
{:else}
<VolumeTwo />
{/if}
</Button>
</Popover.Trigger>
<Popover.Content class="w-2 h-32" side="top">
<Popover.Content class="w-10 h-32 !min-w-0" side="top">
<Slider type="single" orientation="vertical" bind:value max={1} step={0.01} />
</Popover.Content>
</Popover.Root>
</Popover.Root>

View File

@@ -8,7 +8,7 @@
variants: {
variant: {
default:
'bg-neutral-400/10 dark:bg-neutral-600/10 hover:bg-neutral-400/20 dark:hover:bg-neutral-600/20 text-foreground border border-neutral-400/20 dark:border-neutral-600/20 shadow-lg',
'bg-foreground/10 dark:bg-foreground/20 hover:bg-foreground/20 dark:hover:bg-foreground/30 text-foreground border border-neutral-400/20 dark:border-neutral-600/30 shadow-lg',
olddefault: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
outline:

View File

@@ -23,20 +23,28 @@
<DialogPrimitive.Content
bind:ref
class={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
'backdrop-blur-md border drop-shadow-md shadow-lg',
'dark:bg-black/20 bg-white/20 transition border-white/20 dark:border-black/20',
'fixed left-[50%] top-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-300 sm:rounded-2xl',
'bg-white/[0.08] dark:bg-black/[0.45] backdrop-blur-2xl',
'border border-white/[0.12] dark:border-white/[0.08]',
'shadow-[0_8px_32px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.1)]',
'text-white',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'before:absolute before:inset-0 before:rounded-2xl before:bg-gradient-to-br before:from-white/[0.08] before:to-transparent before:pointer-events-none',
className
)}
{...restProps}
>
<div class="text-foreground/90 max-w-none space-y-3 font-medium">
<div class="relative z-[60] text-white max-w-none space-y-3 font-medium">
{@render children?.()}
</div>
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none backdrop-blur-sm p-1 shadow-sm dark:bg-black/20 bg-white/20 border-white/20 dark:border-black/20"
class="absolute right-4 top-4 z-[60] rounded-lg p-2 opacity-60 transition-all duration-200 hover:opacity-100 hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white/20 disabled:pointer-events-none backdrop-blur-sm border border-white/[0.08]"
>
<X class="size-4" color="white" />
<X class="size-4 text-white" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>

View File

@@ -11,6 +11,6 @@
<DialogPrimitive.Description
bind:ref
class={cn("text-black/80 text-sm", className)}
class={cn("text-white/70 text-sm leading-relaxed", className)}
{...restProps}
/>

View File

@@ -13,7 +13,7 @@
<div
bind:this={ref}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
class={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-3 pt-4 mt-2 border-t border-white/[0.08]", className)}
{...restProps}
>
{@render children?.()}

View File

@@ -13,7 +13,7 @@
<div
bind:this={ref}
class={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
class={cn("flex flex-col space-y-2 text-center sm:text-left pb-2", className)}
{...restProps}
>
{@render children?.()}

View File

@@ -12,7 +12,11 @@
<DialogPrimitive.Overlay
bind:ref
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
"fixed inset-0 z-[60]",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"bg-black/40 backdrop-blur-sm",
"before:absolute before:inset-0 before:bg-gradient-to-b before:from-black/20 before:to-transparent",
className
)}
{...restProps}

View File

@@ -11,6 +11,6 @@
<DialogPrimitive.Title
bind:ref
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
class={cn("text-xl font-semibold leading-none tracking-tight text-white drop-shadow-sm", className)}
{...restProps}
/>

View File

@@ -18,7 +18,7 @@
bind:ref
{sideOffset}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'bg-popover text-popover-foreground z-[100] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none',
'bg-white/10 backdrop-blur-md transition hover:bg-white/15 text-foreground border border-white/20 shadow-lg',
className

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
class={cn(
"flex h-10 w-full rounded-lg px-3 py-2 text-sm transition-all duration-200",
"bg-white/[0.06] dark:bg-black/20 backdrop-blur-sm",
"border border-white/[0.1] dark:border-white/[0.08]",
"placeholder:text-white/40 text-white",
"focus:outline-none focus:ring-2 focus:ring-white/20 focus:border-white/20",
"disabled:cursor-not-allowed disabled:opacity-50",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
class={cn(
"flex h-10 w-full rounded-lg px-3 py-2 text-sm transition-all duration-200",
"bg-white/[0.06] dark:bg-black/20 backdrop-blur-sm",
"border border-white/[0.1] dark:border-white/[0.08]",
"placeholder:text-white/40 text-white",
"focus:outline-none focus:ring-2 focus:ring-white/20 focus:border-white/20",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
class={cn(
"text-sm font-medium leading-none text-white/90 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -39,7 +39,7 @@ get along, so we shut typescript up by casting `value` to `never`.
index={thumb}
class={cn(
"border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50",
"bg-white/10 backdrop-blur-md hover:bg-white/15 text-foreground border border-white/20 shadow-lg translate-z-0 relative z-20",
"bg-foreground backdrop-blur-md hover:bg-foreground/85 text-foreground border border-foreground/20 shadow-lg translate-z-0 relative z-20",
)}
/>
{/each}

View File

@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
"focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
class={cn(
"bg-background pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>

View File

@@ -112,8 +112,8 @@
{#if show}
<div
bind:this={windowRef}
class="fixed flex flex-col bg-white/10 backdrop-blur-md border border-white/20 shadow-lg rounded-lg overflow-hidden"
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex};"
class="fixed flex flex-col overflow-hidden rounded-2xl border border-white/[0.1] bg-black/[0.48] text-white shadow-[0_8px_32px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.1)] backdrop-blur-2xl before:pointer-events-none before:absolute before:inset-0 before:rounded-2xl before:bg-gradient-to-br before:from-white/[0.08] before:to-transparent"
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex}; --foreground: 0 0% 98%; --muted-foreground: 0 0% 72%; --text-color: rgba(255, 255, 255, 0.92); --text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);"
onmousedown={handleMouseDown}
role="dialog"
tabindex="0"
@@ -121,14 +121,14 @@
{#if showTitleBar}
<div
bind:this={headerRef}
class="h-8 px-3 flex items-center justify-between bg-black/10 border-b border-white/10 select-none"
class="relative z-10 flex h-8 items-center justify-between border-b border-white/[0.08] bg-black/10 px-3 select-none"
style="cursor: {isDragging ? 'grabbing' : 'grab'};"
>
<span class="text-sm font-medium text-white/90">{title}</span>
{#if showCloseButton}
<button
onclick={onClose}
class="w-5 h-5 flex items-center justify-center text-white/70 hover:text-white hover:bg-red-500/50 rounded-sm transition-colors"
class="flex h-5 w-5 items-center justify-center rounded-sm text-white/70 transition-colors hover:bg-red-500/45 hover:text-white"
aria-label="Close window"
>
<X class="size-4" />
@@ -137,8 +137,8 @@
</div>
{/if}
<div class="flex-1 p-1 overflow-auto bg-transparent" role="dialog" tabindex="0">
<div class="relative z-10 flex-1 overflow-auto bg-transparent p-1" role="dialog" tabindex="0">
{@render children?.()}
</div>
</div>
{/if}
{/if}

View File

@@ -1 +1 @@
// place files you want to import through the `$lib` alias in this folder.
export { authClient } from './auth-client';

234
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,234 @@
import { APIError, betterAuth, type BetterAuthPlugin } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { createAuthEndpoint } from 'better-auth/api';
import { setSessionCookie } from 'better-auth/cookies';
import { anonymous } from 'better-auth/plugins';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import type { D1Database } from '@cloudflare/workers-types';
import { env } from '$env/dynamic/private';
import { dev } from '$app/environment';
import { getRequestEvent } from '$app/server';
import { getDb } from '$lib/server/db';
import { passkey } from '@better-auth/passkey';
import * as z from 'zod';
const generateAccountNumber = () =>
Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => (value % 10).toString()).join('');
const generateOpaqueIdentifier = () => `${crypto.randomUUID()}@internal.invalid`;
const getAnonymousDisplayName = (name?: string | null) => {
const trimmedName = name?.trim();
return trimmedName ? trimmedName : 'Chillhop listener';
};
type TurnstileVerifyResult = {
success: boolean;
hostname?: string;
['error-codes']?: string[];
};
const getClientIpAddress = () => {
const headers = getRequestEvent().request.headers;
return headers.get('CF-Connecting-IP') ?? headers.get('X-Forwarded-For') ?? undefined;
};
const verifyTurnstileToken = async (token: string) => {
const secretKey = dev ? '1x0000000000000000000000000000000AA' : env.TURNSTILE_SECRET_KEY;
if (!secretKey) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Turnstile secret key is not configured',
});
}
const verificationBody = new FormData();
verificationBody.set('secret', secretKey);
verificationBody.set('response', token);
const remoteIp = getClientIpAddress();
if (remoteIp) {
verificationBody.set('remoteip', remoteIp);
}
verificationBody.set('idempotency_key', crypto.randomUUID());
const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: verificationBody,
});
if (!response.ok) {
throw new APIError('BAD_REQUEST', {
message: 'Turnstile verification failed. Please try again.',
});
}
const result = (await response.json()) as TurnstileVerifyResult;
if (!result.success) {
throw new APIError('BAD_REQUEST', {
message:
result['error-codes']?.includes('timeout-or-duplicate')
? 'Turnstile check expired. Please try again.'
: 'Turnstile verification failed. Please try again.',
});
}
};
const createAnonymousSession = async (ctx: any, name?: string | null) => {
const user = await ctx.context.internalAdapter.createUser({
email: generateOpaqueIdentifier(),
emailVerified: false,
isAnonymous: true,
name: getAnonymousDisplayName(name),
createdAt: new Date(),
updatedAt: new Date(),
});
if (!user) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Failed to create user',
});
}
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Failed to create session',
});
}
await setSessionCookie(
ctx,
{ session, user } as Parameters<typeof setSessionCookie>[1],
);
return {
token: session.token,
user,
};
};
const accountNumber = () =>
({
id: 'account-number',
endpoints: {
createAccount: createAuthEndpoint(
'/create-account',
{
method: 'POST',
body: z.object({
name: z.string().trim().max(100).optional(),
turnstileToken: z.string().min(1),
}),
},
async (ctx) => {
await verifyTurnstileToken(ctx.body.turnstileToken);
return ctx.json(await createAnonymousSession(ctx, ctx.body.name));
},
),
signInAccountNumber: createAuthEndpoint(
'/sign-in/account-number',
{
method: 'POST',
body: z.object({
accountNumber: z.string().length(16),
}),
},
async (ctx) => {
const user = (await ctx.context.adapter.findOne({
model: 'user',
where: [
{
field: 'accountNumber',
value: ctx.body.accountNumber,
},
],
})) as (Record<string, any> | null);
if (!user) {
throw new APIError('UNAUTHORIZED', {
message: 'Invalid account number',
});
}
const session = await ctx.context.internalAdapter.createSession(user.id);
if (!session) {
throw new APIError('INTERNAL_SERVER_ERROR', {
message: 'Failed to create session',
});
}
await setSessionCookie(
ctx,
{ session, user } as Parameters<typeof setSessionCookie>[1],
);
return ctx.json({
token: session.token,
user,
});
},
),
},
}) satisfies BetterAuthPlugin;
const createAuthConfig = (baseURL = env.ORIGIN) =>
({
baseURL,
secret: env.BETTER_AUTH_SECRET,
user: {
additionalFields: {
accountNumber: {
type: 'string',
required: true,
input: false,
unique: true,
fieldName: 'account_number',
defaultValue: generateAccountNumber,
},
statisticsOptOut: {
type: 'boolean',
required: true,
input: false,
fieldName: 'statistics_opt_out',
defaultValue: false,
},
},
},
logger: {
level: 'debug',
},
onAPIError: {
onError(error: unknown) {
console.error('Better Auth API error', error);
},
},
plugins: [
anonymous({
generateName: () => getAnonymousDisplayName(),
generateRandomEmail: generateOpaqueIdentifier,
}),
accountNumber(),
passkey({
rpID: new URL(baseURL).hostname,
rpName: 'Chillhop',
}),
sveltekitCookies(getRequestEvent), // make sure this is the last plugin in the array
],
}) satisfies Omit<Parameters<typeof betterAuth>[0], 'database'>;
export const createAuth = (d1: D1Database, baseURL = env.ORIGIN) =>
betterAuth({
...createAuthConfig(baseURL),
database: drizzleAdapter(getDb(d1), { provider: 'sqlite' }),
});
/**
* DO NOT USE!
*
* This instance is used by the `better-auth` CLI for schema generation ONLY.
* To access `auth` at runtime, use `event.locals.auth`.
*/
export const auth = createAuth(null!, 'http://localhost');

View File

@@ -0,0 +1,141 @@
import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.default(false)
.notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
account_number: text("account_number").notNull().unique(),
statisticsOptOut: integer("statistics_opt_out", { mode: "boolean" }).default(false).notNull(),
});
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = sqliteTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp_ms",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp_ms",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = sqliteTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const passkey = sqliteTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
createdAt: integer("created_at", { mode: "timestamp_ms" }),
aaguid: text("aaguid"),
},
(table) => [
index("passkey_userId_idx").on(table.userId),
index("passkey_credentialID_idx").on(table.credentialID),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
passkeys: many(passkey),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
export const passkeyRelations = relations(passkey, ({ one }) => ({
user: one(user, {
fields: [passkey.userId],
references: [user.id],
}),
}));

View File

@@ -0,0 +1,5 @@
import { drizzle } from 'drizzle-orm/d1';
import type { D1Database } from '@cloudflare/workers-types';
import * as schema from './schema';
export const getDb = (d1: D1Database) => drizzle(d1, { schema });

View File

@@ -0,0 +1,39 @@
import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { user } from './auth.schema';
import { sql } from 'drizzle-orm';
export const userStatBucket = sqliteTable('user_stat_bucket', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
metric: text('metric').notNull(),
value: integer('value').notNull(), // seconds, count, etc.
bucketStart: integer('bucket_start', { mode: 'timestamp_ms' }).notNull(),
stationId: integer('station_id').notNull().default(0),
fileId: text('file_id').notNull().default(''),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
}, (table) => [
index('user_stat_bucket_user_metric_bucket_idx').on(table.userId, table.metric, table.bucketStart),
uniqueIndex('user_stat_bucket_unique_idx').on(
table.userId,
table.metric,
table.bucketStart,
table.stationId,
table.fileId,
),
]);
export const songIds = sqliteTable('song_ids', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
fileId: text('file_id').notNull(),
spotifyId: text('spotify_id'),
title: text('title').notNull(),
artists: text('artists').notNull(),
image: text('image').notNull(),
label: text('label'),
}, (table) => [
uniqueIndex('song_ids_file_id_idx').on(table.fileId),
]);
export * from './auth.schema';

View File

@@ -31,6 +31,14 @@ export const state = $state({
workRuleTimer: 20 * 60,
restRuleTimer: 20,
showPomodoro: false,
pomodoroTimer: 20 * 60,
pomodoroBreakTimer: 5 * 60,
isPomodoroActive: false,
pomodoroWorkPhase: true,
showStats: false,
// in daemon.svelte
togglePlay: (() => {}) as () => void,
});

View File

@@ -1,18 +1,55 @@
import type { CHSong, Song } from "@/types";
import { getRequestEvent } from '$app/server';
import { getDb } from '@/server/db';
import { songIds } from '@/server/db/schema';
import type { CHSong, Song } from '@/types';
import { and, eq } from 'drizzle-orm';
export async function getChillhopStation(id: number): Promise<Song[]> {
const res = await fetch(`https://stream.chillhop.com/live/${id}`);
const data = await res.json() as CHSong[];
const finalData = data.map(song => ({
const data = (await res.json()) as CHSong[];
const finalData = data.map((song) => ({
fileId: String(song.fileId),
artists: song.artists,
title: song.title,
endpoint: `https://stream.chillhop.com/mp3/${song.fileId}`,
endpoint: `/api/chstream/${song.fileId}`,
image: song.image,
label: 'Chillhop Music',
spotifyId: song.spotifyId,
duration: song.duration,
duration: song.duration ?? 0,
})) as Song[];
// should not await because it doesn't need to be done before returning the data
for (const song of finalData) {
analyticsData(song).catch((err) => {
console.error('Failed to store analytics data for song:', song.title, err);
});
}
return finalData;
}
async function analyticsData(song: Song) {
const db = getRequestDb();
const existingSong = await db.select().from(songIds).where(and(eq(songIds.title, song.title), eq(songIds.artists, song.artists))).get();
if (existingSong) return;
await db.insert(songIds).values({
fileId: song.fileId,
spotifyId: song.spotifyId,
title: song.title,
artists: song.artists,
image: song.image,
label: song.label,
});
}
function getRequestDb() {
const d1 = getRequestEvent().platform?.env.DB;
if (!d1) {
throw new Error('D1 binding "DB" not found');
}
return getDb(d1);
}

View File

@@ -1,4 +1,5 @@
export interface Song {
fileId: string;
artists: string;
title: string;
endpoint: string;

View File

@@ -7,6 +7,7 @@
import BackgroundAnalyzer from '@/components/app/bg-analyzer.svelte';
import Title from '@/components/app/title.svelte';
import LeftBar from '@/components/app/left-bar.svelte';
import AuthBar from '@/components/app/auth-bar.svelte';
</script>
<BgImage />
@@ -20,7 +21,7 @@
</div>
{:else if state.isLoading && state.hasInteracted}
<div class="flex flex-col h-screen w-full items-center justify-center space-y-2">
<Spinner class="size-10" />
<Spinner class="size-10 animate-spin" />
<p>Loading...</p>
</div>
{:else if state.error}
@@ -29,6 +30,7 @@
</div>
{:else if state.hasInteracted}
<Title />
<AuthBar />
<LeftBar />
<BottomBar />
{/if}

View File

@@ -0,0 +1,28 @@
import { json } from '@sveltejs/kit';
import { eq } from 'drizzle-orm';
import { getDb } from '@/server/db';
import { user as userTable } from '@/server/db/schema';
import type { RequestHandler } from './$types';
type StatisticsOptOutBody = {
statisticsOptOut?: unknown;
};
export const PATCH: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await event.request.json().catch(() => ({}))) as StatisticsOptOutBody;
if (typeof body.statisticsOptOut !== 'boolean') {
return json({ error: 'Invalid statistics preference' }, { status: 400 });
}
await getDb(event.platform!.env.DB)
.update(userTable)
.set({ statisticsOptOut: body.statisticsOptOut, updatedAt: new Date() })
.where(eq(userTable.id, user.id));
return json({ statisticsOptOut: body.statisticsOptOut });
};

View File

@@ -0,0 +1,57 @@
// overengineered proxy brought to you by codex.
import type { RequestHandler } from '@sveltejs/kit';
const FILE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
function isValidFileId(fileId: string) {
return FILE_ID_PATTERN.test(fileId);
}
export const GET: RequestHandler = async ({ params, request, fetch }) => {
const { fileId } = params;
if (!fileId || !isValidFileId(fileId)) {
return new Response('Invalid file ID', { status: 400 });
}
const upstreamHeaders = new Headers();
const range = request.headers.get('range');
if (range) {
upstreamHeaders.set('range', range);
}
const upstreamResponse = await fetch(`https://stream.chillhop.com/mp3/${fileId}`, {
headers: upstreamHeaders,
});
if (!upstreamResponse.ok && upstreamResponse.status !== 206) {
return new Response('File not found', { status: upstreamResponse.status === 404 ? 404 : 502 });
}
const responseHeaders = new Headers();
const headersToForward = [
'content-type',
'content-length',
'content-range',
'accept-ranges',
'etag',
'last-modified',
'cache-control',
];
for (const header of headersToForward) {
const value = upstreamResponse.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
}
responseHeaders.set('Content-Disposition', `inline; filename="${fileId}.mp3"`);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
});
};

View File

@@ -0,0 +1,74 @@
import { json } from '@sveltejs/kit';
import { sql } from 'drizzle-orm';
import { getDb } from '@/server/db';
import { userStatBucket } from '@/server/db/schema';
import type { RequestHandler } from './$types';
type ListenBody = {
fileId?: unknown;
stationId?: unknown;
seconds?: unknown;
};
const LISTEN_METRIC = 'listen_seconds';
const BUCKET_SIZE_MS = 60 * 60 * 1000;
const getHourlyBucketStart = (date = new Date()) =>
new Date(Math.floor(date.getTime() / BUCKET_SIZE_MS) * BUCKET_SIZE_MS);
const clampListenSeconds = (seconds: unknown) => {
const parsed = typeof seconds === 'number' ? seconds : Number(seconds);
if (!Number.isFinite(parsed)) {
return 30;
}
return Math.max(1, Math.min(60, Math.round(parsed)));
};
export const POST: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
if (user.statisticsOptOut) {
return json({ ok: true, skipped: true });
}
const body = (await event.request.json().catch(() => ({}))) as ListenBody;
const fileId = typeof body.fileId === 'string' ? body.fileId.trim() : '';
const stationId = typeof body.stationId === 'number' ? body.stationId : Number(body.stationId);
if (!fileId || !Number.isInteger(stationId)) {
return json({ error: 'Invalid listen payload' }, { status: 400 });
}
const seconds = clampListenSeconds(body.seconds);
const db = getDb(event.platform!.env.DB);
await db
.insert(userStatBucket)
.values({
userId: user.id,
metric: LISTEN_METRIC,
value: seconds,
bucketStart: getHourlyBucketStart(),
stationId,
fileId,
})
.onConflictDoUpdate({
target: [
userStatBucket.userId,
userStatBucket.metric,
userStatBucket.bucketStart,
userStatBucket.stationId,
userStatBucket.fileId,
],
set: {
value: sql`${userStatBucket.value} + ${seconds}`,
},
});
return json({ ok: true });
};

View File

@@ -0,0 +1,67 @@
import { json } from '@sveltejs/kit';
import { and, desc, eq, gte, sql } from 'drizzle-orm';
import { getDb } from '@/server/db';
import { songIds, userStatBucket } from '@/server/db/schema';
import type { RequestHandler } from './$types';
const LISTEN_METRIC = 'listen_seconds';
const getUtcDayStart = (date = new Date()) =>
new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
export const GET: RequestHandler = async (event) => {
const user = event.locals.user;
if (!user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const db = getDb(event.platform!.env.DB);
const todayStart = getUtcDayStart();
const listenFilter = and(
eq(userStatBucket.userId, user.id),
eq(userStatBucket.metric, LISTEN_METRIC),
);
const [totalListen] = await db
.select({ seconds: sql<number>`coalesce(sum(${userStatBucket.value}), 0)` })
.from(userStatBucket)
.where(listenFilter);
const [todayListen] = await db
.select({ seconds: sql<number>`coalesce(sum(${userStatBucket.value}), 0)` })
.from(userStatBucket)
.where(and(listenFilter, gte(userStatBucket.bucketStart, todayStart)));
const topSongs = await db
.select({
fileId: userStatBucket.fileId,
title: songIds.title,
artists: songIds.artists,
image: songIds.image,
seconds: sql<number>`sum(${userStatBucket.value})`,
})
.from(userStatBucket)
.leftJoin(songIds, eq(userStatBucket.fileId, songIds.fileId))
.where(listenFilter)
.groupBy(userStatBucket.fileId, songIds.title, songIds.artists, songIds.image)
.orderBy(desc(sql`sum(${userStatBucket.value})`))
.limit(5);
const topStations = await db
.select({
stationId: userStatBucket.stationId,
seconds: sql<number>`sum(${userStatBucket.value})`,
})
.from(userStatBucket)
.where(listenFilter)
.groupBy(userStatBucket.stationId)
.orderBy(desc(sql`sum(${userStatBucket.value})`))
.limit(5);
return json({
totalSeconds: totalListen?.seconds ?? 0,
todaySeconds: todayListen?.seconds ?? 0,
topSongs,
topStations,
});
};

2
static/.assetsignore Normal file
View File

@@ -0,0 +1,2 @@
_worker.js
_routes.json

View File

@@ -6,13 +6,7 @@ const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
'@/*': './src/lib/*',
},
},
kit: { adapter: adapter(), alias: { '@/*': './src/lib/*' } }
};
export default config;

View File

@@ -9,11 +9,9 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
"moduleResolution": "bundler",
"types": [
"./worker-configuration.d.ts"
]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

30
wrangler.jsonc Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "lofi",
"compatibility_date": "2026-03-17",
"compatibility_flags": ["nodejs_compat"],
"main": ".svelte-kit/cloudflare/_worker.js",
"assets": {
"binding": "ASSETS",
"directory": ".svelte-kit/cloudflare",
},
"workers_dev": true,
"preview_urls": true,
"observability": {
"enabled": true,
},
"routes": [
{
"pattern": "lofi.srizan.dev",
"custom_domain": true,
},
],
"d1_databases": [
{
"binding": "DB",
"database_name": "lofi_db",
"database_id": "c8b599fc-88ce-4b55-b386-bfffe122d5f8",
"migrations_dir": "drizzle",
},
],
}

2124
yarn.lock

File diff suppressed because it is too large Load Diff