mirror of
https://github.com/SrIzan10/lofi.git
synced 2026-06-06 00:56:53 +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,
|
"when": 1777011840981,
|
||||||
"tag": "0005_chubby_master_mold",
|
"tag": "0005_chubby_master_mold",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1777041984571,
|
||||||
|
"tag": "0006_known_avengers",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { Turnstile } from 'svelte-turnstile';
|
import { Turnstile } from 'svelte-turnstile';
|
||||||
import { dev } from '$app/environment';
|
import { dev } from '$app/environment';
|
||||||
import { env as publicEnv } from '$env/dynamic/public';
|
import { env as publicEnv } from '$env/dynamic/public';
|
||||||
|
import Switch from '../ui/switch/switch.svelte';
|
||||||
|
|
||||||
const session = authClient.useSession();
|
const session = authClient.useSession();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
let loadedPasskeysForUserId = $state<string | null>(null);
|
let loadedPasskeysForUserId = $state<string | null>(null);
|
||||||
let authScreen = $state<'login' | 'create'>('login');
|
let authScreen = $state<'login' | 'create'>('login');
|
||||||
let passkeyName = $state('');
|
let passkeyName = $state('');
|
||||||
|
let statisticsMessage = $state('');
|
||||||
let turnstileToken = $state('');
|
let turnstileToken = $state('');
|
||||||
let resetTurnstile = $state<(() => void) | undefined>();
|
let resetTurnstile = $state<(() => void) | undefined>();
|
||||||
let createFormElement = $state<HTMLDivElement>();
|
let createFormElement = $state<HTMLDivElement>();
|
||||||
@@ -161,6 +163,28 @@
|
|||||||
|
|
||||||
const signOut = () => runAuthAction('sign-out', () => authClient.signOut(), 'Sign out 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 () => {
|
const addPasskey = async () => {
|
||||||
if (!passkeyName) {
|
if (!passkeyName) {
|
||||||
passkeyMessage = 'Please enter a name for your passkey';
|
passkeyMessage = 'Please enter a name for your passkey';
|
||||||
@@ -243,6 +267,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-y-3">
|
||||||
<h3 class="text-sm font-medium text-white/80">Passkeys</h3>
|
<h3 class="text-sm font-medium text-white/80">Passkeys</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
import TodoList from './todo-list.svelte';
|
import TodoList from './todo-list.svelte';
|
||||||
import Twentytwentytwenty from './twentytwentytwenty.svelte';
|
import Twentytwentytwenty from './twentytwentytwenty.svelte';
|
||||||
import Pomodoro from './pomodoro.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
|
// svelte-ignore non_reactive_update
|
||||||
let audioElement: HTMLAudioElement;
|
let audioElement: HTMLAudioElement;
|
||||||
@@ -167,6 +172,7 @@
|
|||||||
!appState.isPlaying ||
|
!appState.isPlaying ||
|
||||||
!appState.currentSong ||
|
!appState.currentSong ||
|
||||||
!appState.currentStation ||
|
!appState.currentStation ||
|
||||||
|
user?.statisticsOptOut ||
|
||||||
!audioElement ||
|
!audioElement ||
|
||||||
audioElement.paused
|
audioElement.paused
|
||||||
) {
|
) {
|
||||||
@@ -300,3 +306,15 @@
|
|||||||
>
|
>
|
||||||
<Pomodoro></Pomodoro>
|
<Pomodoro></Pomodoro>
|
||||||
</Window>
|
</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 Button from '../ui/button/button.svelte';
|
||||||
import Binoculars from '@lucide/svelte/icons/binoculars';
|
import Binoculars from '@lucide/svelte/icons/binoculars';
|
||||||
import Clock from '@lucide/svelte/icons/clock';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import ChartNoAxesColumn from '@lucide/svelte/icons/chart-no-axes-column';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -24,4 +25,10 @@
|
|||||||
>
|
>
|
||||||
<Clock class="size-4" />
|
<Clock class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
onclick={() => (appState.showStats = !appState.showStats)}
|
||||||
|
>
|
||||||
|
<ChartNoAxesColumn class="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</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
|
<DialogPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
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',
|
'bg-white/[0.08] dark:bg-black/[0.45] backdrop-blur-2xl',
|
||||||
'border border-white/[0.12] dark:border-white/[0.08]',
|
'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)]',
|
'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}
|
{...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?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<DialogPrimitive.Close
|
<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" />
|
<X class="size-4 text-white" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">Close</span>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
bind:ref
|
bind:ref
|
||||||
class={cn(
|
class={cn(
|
||||||
"fixed inset-0 z-50",
|
"fixed inset-0 z-[60]",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
"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]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
"bg-black/40 backdrop-blur-sm",
|
"bg-black/40 backdrop-blur-sm",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
bind:ref
|
bind:ref
|
||||||
{sideOffset}
|
{sideOffset}
|
||||||
class={cn(
|
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',
|
'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',
|
'bg-white/10 backdrop-blur-md transition hover:bg-white/15 text-foreground border border-white/20 shadow-lg',
|
||||||
className
|
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}
|
{#if show}
|
||||||
<div
|
<div
|
||||||
bind:this={windowRef}
|
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};"
|
style="width: {width}px; height: {height}px; left: {x}px; top: {y}px; z-index: {zIndex};"
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
|
|||||||
@@ -187,6 +187,13 @@ const authConfig = {
|
|||||||
fieldName: 'account_number',
|
fieldName: 'account_number',
|
||||||
defaultValue: generateAccountNumber,
|
defaultValue: generateAccountNumber,
|
||||||
},
|
},
|
||||||
|
statisticsOptOut: {
|
||||||
|
type: 'boolean',
|
||||||
|
required: true,
|
||||||
|
input: false,
|
||||||
|
fieldName: 'statistics_opt_out',
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
logger: {
|
logger: {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const user = sqliteTable("user", {
|
|||||||
.notNull(),
|
.notNull(),
|
||||||
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
|
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
|
||||||
account_number: text("account_number").notNull().unique(),
|
account_number: text("account_number").notNull().unique(),
|
||||||
|
statisticsOptOut: integer("statistics_opt_out", { mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = sqliteTable(
|
export const session = sqliteTable(
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export const state = $state({
|
|||||||
isPomodoroActive: false,
|
isPomodoroActive: false,
|
||||||
pomodoroWorkPhase: true,
|
pomodoroWorkPhase: true,
|
||||||
|
|
||||||
|
showStats: false,
|
||||||
|
|
||||||
// in daemon.svelte
|
// in daemon.svelte
|
||||||
togglePlay: (() => {}) as () => void,
|
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 });
|
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 body = (await event.request.json().catch(() => ({}))) as ListenBody;
|
||||||
const fileId = typeof body.fileId === 'string' ? body.fileId.trim() : '';
|
const fileId = typeof body.fileId === 'string' ? body.fileId.trim() : '';
|
||||||
const stationId = typeof body.stationId === 'number' ? body.stationId : Number(body.stationId);
|
const stationId = typeof body.stationId === 'number' ? body.stationId : Number(body.stationId);
|
||||||
|
|||||||
Reference in New Issue
Block a user