mirror of
https://github.com/SrIzan10/lofi.git
synced 2026-06-05 16:46:55 +00:00
feat: listening stats!!!!
This commit is contained in:
1
drizzle/0006_known_avengers.sql
Normal file
1
drizzle/0006_known_avengers.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `user` ADD `statistics_opt_out` integer DEFAULT false NOT NULL;
|
||||
687
drizzle/meta/0006_snapshot.json
Normal file
687
drizzle/meta/0006_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1777011840981,
|
||||
"tag": "0005_chubby_master_mold",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1777041984571,
|
||||
"tag": "0006_known_avengers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
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();
|
||||
|
||||
@@ -30,6 +31,7 @@
|
||||
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>();
|
||||
@@ -161,6 +163,28 @@
|
||||
|
||||
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';
|
||||
@@ -243,6 +267,28 @@
|
||||
</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>
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
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;
|
||||
@@ -167,6 +172,7 @@
|
||||
!appState.isPlaying ||
|
||||
!appState.currentSong ||
|
||||
!appState.currentStation ||
|
||||
user?.statisticsOptOut ||
|
||||
!audioElement ||
|
||||
audioElement.paused
|
||||
) {
|
||||
@@ -300,3 +306,15 @@
|
||||
>
|
||||
<Pomodoro></Pomodoro>
|
||||
</Window>
|
||||
|
||||
<Window
|
||||
title="Stats"
|
||||
showTitleBar={true}
|
||||
showCloseButton={true}
|
||||
width={500}
|
||||
height={400}
|
||||
onClose={() => appState.showStats = false}
|
||||
show={appState.showStats}
|
||||
>
|
||||
<Stats></Stats>
|
||||
</Window>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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
|
||||
@@ -24,4 +25,10 @@
|
||||
>
|
||||
<Clock class="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
onclick={() => (appState.showStats = !appState.showStats)}
|
||||
>
|
||||
<ChartNoAxesColumn class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
157
src/lib/components/app/stats.svelte
Normal file
157
src/lib/components/app/stats.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { state as appState } from '@/state.svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
|
||||
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);
|
||||
|
||||
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';
|
||||
};
|
||||
|
||||
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 {
|
||||
error = 'Could not load stats.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-4 text-foreground">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-foreground/50">Statistics</p>
|
||||
<h2 class="text-2xl font-semibold leading-tight">Listening overview</h2>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
|
||||
Loading stats...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-4 text-sm text-foreground/70">
|
||||
{error}
|
||||
</div>
|
||||
{:else if stats}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">Today</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.todaySeconds)}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-white/10 bg-white/10 p-3">
|
||||
<p class="text-xs uppercase tracking-[0.16em] text-foreground/50">All time</p>
|
||||
<p class="mt-1 text-2xl font-semibold">{formatDuration(stats.totalSeconds)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-medium text-foreground/80">Top stations</h3>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({
|
||||
variant: 'default',
|
||||
size: 'sm',
|
||||
class: 'h-7 px-2 text-xs'
|
||||
})}
|
||||
aria-label="Show top songs"
|
||||
>
|
||||
<ListMusic class="size-3.5" />
|
||||
Songs
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="w-72 max-h-72 overflow-y-auto" align="end">
|
||||
<DropdownMenu.Label>Top songs</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
{#each stats.topSongs as song}
|
||||
<DropdownMenu.Item class="gap-3 p-2">
|
||||
{#if song.image}
|
||||
<img src={song.image} alt="" class="size-9 shrink-0 rounded-md object-cover" />
|
||||
{:else}
|
||||
<div class="size-9 shrink-0 rounded-md bg-white/10"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{song.title ?? song.fileId}</p>
|
||||
<p class="truncate text-xs text-foreground/55">
|
||||
{song.artists ?? 'Unknown artist'}
|
||||
</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-foreground/60">
|
||||
{formatDuration(song.seconds)}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item disabled class="text-foreground/60">
|
||||
No top songs yet
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 space-y-2 overflow-y-auto pr-1">
|
||||
{#each stats.topStations as station}
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm"
|
||||
>
|
||||
<span class="min-w-0 truncate">{stationName(station.stationId)}</span>
|
||||
<span class="shrink-0 text-foreground/60">{formatDuration(station.seconds)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="rounded-lg border border-white/10 bg-white/10 p-3 text-sm text-foreground/60">
|
||||
Station stats will appear after you listen for a bit.
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
<DialogPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-300 sm:rounded-2xl',
|
||||
'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)]',
|
||||
@@ -38,11 +38,11 @@
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="relative z-10 text-white 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 z-20 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]"
|
||||
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 text-white" />
|
||||
<span class="sr-only">Close</span>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<DialogPrimitive.Overlay
|
||||
bind:ref
|
||||
class={cn(
|
||||
"fixed inset-0 z-50",
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
7
src/lib/components/ui/switch/index.ts
Normal file
7
src/lib/components/ui/switch/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./switch.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Switch,
|
||||
};
|
||||
27
src/lib/components/ui/switch/switch.svelte
Normal file
27
src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
@@ -112,7 +112,7 @@
|
||||
{#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"
|
||||
class="fixed flex flex-col bg-foreground/20 backdrop-blur-md border border-foreground/20 shadow-lg rounded-lg overflow-hidden"
|
||||
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex};"
|
||||
onmousedown={handleMouseDown}
|
||||
role="dialog"
|
||||
|
||||
@@ -187,6 +187,13 @@ const authConfig = {
|
||||
fieldName: 'account_number',
|
||||
defaultValue: generateAccountNumber,
|
||||
},
|
||||
statisticsOptOut: {
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
input: false,
|
||||
fieldName: 'statistics_opt_out',
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
|
||||
@@ -18,6 +18,7 @@ export const user = sqliteTable("user", {
|
||||
.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(
|
||||
|
||||
@@ -37,6 +37,8 @@ export const state = $state({
|
||||
isPomodoroActive: false,
|
||||
pomodoroWorkPhase: true,
|
||||
|
||||
showStats: false,
|
||||
|
||||
// in daemon.svelte
|
||||
togglePlay: (() => {}) as () => void,
|
||||
});
|
||||
|
||||
28
src/routes/api/account/statistics-opt-out/+server.ts
Normal file
28
src/routes/api/account/statistics-opt-out/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -32,6 +32,10 @@ export const POST: RequestHandler = async (event) => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user