From 168f586e087be74d6e9ab39a422bcf60646474d6 Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Wed, 5 Mar 2025 03:16:28 -0500 Subject: [PATCH] base of prefs --- bun.lock | 5 + drizzle/0003_solid_the_watchers.sql | 9 + drizzle/meta/0003_snapshot.json | 566 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 1 + public/oneko.gif | Bin 0 -> 3316 bytes .../dashboard/account/settings/page.tsx | 150 ++--- src/app/(pages)/(dashboard)/layout.tsx | 34 +- src/app/api/user/update-preference/route.ts | 21 + .../user/settings/appearance-settings.tsx | 8 - .../user/settings/preference-settings.tsx | 106 ++++ src/components/oneko-kitty.tsx | 292 +++++++++ src/components/ui/checkbox.tsx | 31 + src/components/user/profile-button.tsx | 58 +- src/lib/db/schemas/auth-schema.ts | 95 +-- src/lib/db/schemas/preference.ts | 16 + src/lib/helpers/user.ts | 104 ++-- src/lib/preference.ts | 52 ++ src/lib/utils/cache.ts | 248 ++++++++ 19 files changed, 1580 insertions(+), 223 deletions(-) create mode 100644 drizzle/0003_solid_the_watchers.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 public/oneko.gif create mode 100644 src/app/api/user/update-preference/route.ts delete mode 100644 src/components/dashboard/user/settings/appearance-settings.tsx create mode 100644 src/components/dashboard/user/settings/preference-settings.tsx create mode 100644 src/components/oneko-kitty.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/lib/db/schemas/preference.ts create mode 100644 src/lib/preference.ts create mode 100644 src/lib/utils/cache.ts diff --git a/bun.lock b/bun.lock index 9f649e2..0cfbfc4 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^4.1.2", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "^1.3.2", @@ -267,6 +268,8 @@ "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.2", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.1.4", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw=="], @@ -319,6 +322,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.0", "", { "dependencies": { "@radix-ui/rect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.0", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw=="], diff --git a/drizzle/0003_solid_the_watchers.sql b/drizzle/0003_solid_the_watchers.sql new file mode 100644 index 0000000..51d11f8 --- /dev/null +++ b/drizzle/0003_solid_the_watchers.sql @@ -0,0 +1,9 @@ +CREATE TABLE "preferences" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "show_kitty" boolean, + "webhook_url" text, + CONSTRAINT "preferences_user_id_unique" UNIQUE("user_id") +); +--> statement-breakpoint +ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..071e074 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,566 @@ +{ + "id": "9fb67509-74bd-4e9c-ab7b-45f3d88803c9", + "prevId": "58860b4f-fd72-40e3-81dd-9ede3015e72e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "upload_token": { + "name": "upload_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file": { + "name": "file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "delete_key": { + "name": "delete_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "extension": { + "name": "extension", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "has_thumbnail": { + "name": "has_thumbnail", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "file_user_id_users_id_fk": { + "name": "file_user_id_users_id_fk", + "tableFrom": "file", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.preferences": { + "name": "preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "show_kitty": { + "name": "show_kitty", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "preferences_user_id_users_id_fk": { + "name": "preferences_user_id_users_id_fk", + "tableFrom": "preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "preferences_user_id_unique": { + "name": "preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.thumbnail": { + "name": "thumbnail", + "schema": "", + "columns": { + "thumbnail_id": { + "name": "thumbnail_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "thumbnail_extension": { + "name": "thumbnail_extension", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_size": { + "name": "thumbnail_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "thumbnail_user_id_users_id_fk": { + "name": "thumbnail_user_id_users_id_fk", + "tableFrom": "thumbnail", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 664ac8f..315223d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1741090644906, "tag": "0002_kind_old_lace", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1741161496094, + "tag": "0003_solid_the_watchers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index b2cd0cf..d45d807 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^4.1.2", + "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-icons": "^1.3.2", diff --git a/public/oneko.gif b/public/oneko.gif new file mode 100644 index 0000000000000000000000000000000000000000..a009c2cc19c96b001ac76e96f27e5fa1a9e56577 GIT binary patch literal 3316 zcmV#JsTDI;B0w_$^c6>sLr$^cj^xcLe*^G!JdR{W% zDYwHt6)0h{5-nLJ#^d$tYHp?7C-fsOL$lySt|*L~m$$KqUZ1v_xw-zQ24&{e_HY)` z#FY?82$RTEHB@E?IOX@&0Wn84S;+OrVH9UFIHxrs3VArfX1K}PH3^y_+c)d!+ISJG zC|Zhn*T}m!NZGOW5NM3``)dc3TYQU~yG%9ZqlwbIY>`SjE%l06%&42`R1Qrcy5k%U zxXz00&An*pT=b_L%4V*Err9=Ap#+wC7Ry|pZWH_?neFait39?Fw3ZiR zK(GlX4<*`{9BFZ1zX8eUnD>w53fMxMReZdY%+NwKyA+qgUfyp!sZSox66kXW3b8qq?Nr5BTQVnGr3y{_|5#9g&OwHoB2t;ta#r zdh8ka-dCqNrd?JDv1Lq+j5av)C~{3Nfp*q2ia%KTW+Z1S%}&F2N;JV zqEjJ1>b*GBTn_5@9AOsjmfV63v{O=ylc@M%hbRh28GOz$_YqbVVklT`JD#ZEJCBHQ z-+tWnNMsexXm%WV3LYh9iAxGcUMghq_~DsMe&i8$dHFZcm7K)m<#<}!sOO1sDtVbJ ze}1**L-FOAr!m8&gI=MpK}H~zgqn2IB4kARo{JI{h-hni4%yd8Z)yq|N-_ZhW^_B^ zC}vU8O!wD*$2=JpP?U0+5Cn| z)$-e~zW*u<2NLkYMDW0l;>&Qu4igjT!xB$SF}@2|jB&=eq#)B%j$EuUOCgVp#tI!D zobR{ylKjcaEHj~P6#EJ~BYH1G>@vo`k^u{H%ihLcaxwpG!_I0M>ZTS%*TEskz|J$( zgQV`tfiy)Y)Q=A3z>$SAP9v;t#CVB)q0kFuLa2r?86xgX9fnOdn?Gurvv0*2{o2Z; z_KF&PaW~Ww4N)C6_#N1ig0iYx8y@7>Y^Ckq*r(|;5#Eyj+heMkY8TjI^7& zm*ba~eDu&w#li z;NfYH`u9CP)%Eyi6yi-U48SUujLxOKD0$`#caoHj^cOLcxC&F$G0}{wM~rU0p;(YY zV398PzW9X3E)Y}{rB0|DQ+dsCcEjM>kfy=rh0t~TX;AV?M;slEFoXvz%R;0zKhLqr zT%%iGKu#qu?vXGxl}il`aR|ZM`3Z9SOLt-oS(FqNl+r>k>TkR@&=eUCr$@T=yKM8boiW7 zcJDmSQ%*a|NE&3RvR%laUyTkW9t9Szjr0m)(#FOv-?fRAJ{%XLT1ic`h?AOJ`5X55 zv_T3oCqiNv)gAq5H?H8RT*O4i7JU}bBy#JjZ5=Gho7MK4{vGiucjj_K0r&7k_!d=ZSxYGW45Q%}7Zt##IM2&~R6M)}A<8 z*5hOYtgJ{YSLqtpyy}&JCu6~0{|eZ^UO}o%6Rcswq_cJ=Os04}EJ^Z!P@tLff6tT; zO_kT!iJi`@MYC#W;uI46ILoU%c^5lMrnDwHRB-;&f@i5irvQ49iln{eEr!W0+s)&C z-h%6>1ozrZc2kH`G!q{owm84q=d5-AiP>%28Qf0-H?lvS-=7>+*`Va?cMia+>}Zy) zeATh2{UL9bGQ!NHP&T%0K`t3XDV5FbMu9@nW^^5R!>P)5bSj}%c7L2Tp#Kjdbr#}?MJb!I%v2DehsZXT}xl9MUtWWue^L%bCw@nfwYg7K%3?iH68R1jKS*3NY7 zDX@&|$axkf$!4=oqYoOpF*y2}0u@UX>FGljPdbmV?DVI$$zV}qScmYzk+R@NRaG-G zp8pFpLgR@^ko=OW3ndEJZk+6iindPJh30Zsd5&r0IZ5aXhxo9~JNlk9Y+E7l2SW{O zS1)@hH+F6{y_dfm&GmxrHu0CfylysI709(A3kM(E=RZd~fh$gTo^QnAN^g6<*z}4< zH8LM5-#5u@47h^|-sa=Tx}o>{>Ynw<--gfX;pB>VpzHl{`2sXi%NUl!+orFFNptde{UayV<6%SEfdLt1& z+O}X#f3!9HfMrncJU)GZP!J|w?g4Jb<_r5RY6^5WH^JAbGt+x zJOYFBCRs9wfzk(b-N!2hc7vRjao3h%WyVQE2s~XzI1f~WVn!(OVkJUmZ2@R;LDz#d z=t5aYHqh3EDaL{=Xl0Zpa$hAzTR437^4m_ZK?XJ)<@zxAsPzrXPow zVGHMROq4PV=Z0EBgjkp|KC?7&Xg<5fQtw1+QDkM!XKd2feXsIbj%I24_JrytCD}u8 zjOd3gsE5`6PmmV}qq94NSPtF>M<2wy8j5HwLG+7u;~wrMRRS}Mkh ztvFC)p;NyDC;Fvwv&3EL*B(FD6fR~g0t9P*G$hT1Wghr;BNk%H$Qf7Cj0dKONBAzM zD2mdgjpV3DK=m$m=Wsb?YdSbM8TdGGsDH93Y$`D2?POM=moH*cennav>-rk9j6c zD5nPp^I`s!V}+I(7?*DS#B2EnJE<0XBxjQ&7U0un5fZ~Z@Czi)FP9?Wp>Eb*1 zGkIPDi&N8D1C<+dqf0&qR{wQ2PNFMRLla_^e0jrDAV(Pr=aQ)6G%@Fc4rh1!R+G4b zCZsf%vXPenlQn`DY?l{zXai!gS6yy&KA0(sWEX}N_%M?vNKt4oCU}3C;dlsxF50$} yo73M10028!@k;#w literal 0 HcmV?d00001 diff --git a/src/app/(pages)/(dashboard)/dashboard/account/settings/page.tsx b/src/app/(pages)/(dashboard)/dashboard/account/settings/page.tsx index b251016..a607ed3 100644 --- a/src/app/(pages)/(dashboard)/dashboard/account/settings/page.tsx +++ b/src/app/(pages)/(dashboard)/dashboard/account/settings/page.tsx @@ -1,6 +1,6 @@ -import AppearanceSettings from "@/components/dashboard/user/settings/appearance-settings"; import ConfigSettings from "@/components/dashboard/user/settings/config-settings"; import NotificationSettings from "@/components/dashboard/user/settings/notification-settings"; +import PreferenceSettings from "@/components/dashboard/user/settings/preference-settings"; import UserSettings from "@/components/dashboard/user/settings/user-settings"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UserType } from "@/lib/db/schemas/auth-schema"; @@ -8,87 +8,87 @@ import { getUser } from "@/lib/helpers/user"; import { Metadata } from "next"; const tabs = [ - { - id: "user", - name: "User", - description: "Manage your account settings and preferences.", - content: () => , - }, - { - id: "config", - name: "Config", - description: "Manage your upload client configurations.", - content: (user: UserType) => , - }, - { - id: "notifications", - name: "Notifications", - description: "Manage your email notification settings.", - content: () => , - }, - { - id: "appearance", - name: "Appearance", - description: "Manage your website appearance settings.", - content: () => , - }, + { + id: "user", + name: "User", + description: "Manage your account settings and preferences.", + content: () => , + }, + { + id: "config", + name: "Config", + description: "Manage your upload client configurations.", + content: (user: UserType) => , + }, + { + id: "notifications", + name: "Notifications", + description: "Manage your email notification settings.", + content: () => , + }, + { + id: "preferences", + name: "Preferences", + description: "Manage your website preference settings.", + content: (user: UserType) => , + }, ]; export const metadata: Metadata = { - title: "Account Settings", + title: "Account Settings", }; export default async function AccountSettingsPage() { - const user = await getUser(); - return ( -
- {/* Header */} -
-

- Account Settings -

-

- Manage your account settings and preferences. -

-
+ const user = await getUser(); + return ( +
+ {/* Header */} +
+

+ Account Settings +

+

+ Manage your account settings and preferences. +

+
- {/* Settings */} - - {/* Headers */} - - {tabs.map(tab => ( - - {tab.name} - - ))} - + {/* Settings */} + + {/* Headers */} + + {tabs.map((tab) => ( + + {tab.name} + + ))} + - {/* Content */} - {tabs.map(tab => ( - - {/* Tab Header */} -
-

- {tab.name} -

-

- {tab.description} -

-
+ {/* Content */} + {tabs.map((tab) => ( + + {/* Tab Header */} +
+

+ {tab.name} +

+

+ {tab.description} +

+
- {/* Tab Content */} - {tab.content(user)} -
- ))} -
-
- ); + {/* Tab Content */} + {tab.content(user)} + + ))} + +
+ ); } diff --git a/src/app/(pages)/(dashboard)/layout.tsx b/src/app/(pages)/(dashboard)/layout.tsx index d70f45a..0ec951e 100644 --- a/src/app/(pages)/(dashboard)/layout.tsx +++ b/src/app/(pages)/(dashboard)/layout.tsx @@ -1,21 +1,25 @@ import AppSidebar from "@/components/dashboard/sidebar/sidebar"; +import OnekoKitty from "@/components/oneko-kitty"; import { SidebarInset } from "@/components/ui/sidebar"; +import { getUser } from "@/lib/helpers/user"; -export default function DashboardLayout({ - children, +export default async function DashboardLayout({ + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
- - -
-
- {children} -
-
-
-
- ); + const user = await getUser(); + return ( +
+ + +
+
+ {children} +
+
+ {user.preferences?.showKitty && } +
+
+ ); } diff --git a/src/app/api/user/update-preference/route.ts b/src/app/api/user/update-preference/route.ts new file mode 100644 index 0000000..8364926 --- /dev/null +++ b/src/app/api/user/update-preference/route.ts @@ -0,0 +1,21 @@ +import { authError } from "@/lib/api-commons"; +import { auth } from "@/lib/auth"; +import { updateUserPreferences } from "@/lib/preference"; +import { ApiErrorResponse, ApiSuccessResponse } from "@/type/api/responses"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +export async function POST( + request: Request +): Promise> { + const requestHeaders = await headers(); + const session = await auth.api.getSession({ + headers: requestHeaders, + }); + if (!session) { + return authError; + } + const { showKitty, webhookUrl } = await request.json(); + await updateUserPreferences(session.user.id, { showKitty, webhookUrl }); + return NextResponse.json({ message: "Preference Update" }, { status: 200 }); +} diff --git a/src/components/dashboard/user/settings/appearance-settings.tsx b/src/components/dashboard/user/settings/appearance-settings.tsx deleted file mode 100644 index bd40a27..0000000 --- a/src/components/dashboard/user/settings/appearance-settings.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function AppearanceSettings() { - return ( -
- Appearance settings are not yet a thing - ideas: kitty cat, theme, sidebar - toggle -
- ); -} diff --git a/src/components/dashboard/user/settings/preference-settings.tsx b/src/components/dashboard/user/settings/preference-settings.tsx new file mode 100644 index 0000000..aa701b3 --- /dev/null +++ b/src/components/dashboard/user/settings/preference-settings.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { UserType } from "@/lib/db/schemas/auth-schema"; +import request from "@/lib/request"; +import { cn } from "@/lib/utils/utils"; +import { Check, Loader2, XCircle } from "lucide-react"; +import { ReactNode, useState } from "react"; + +const statusIcons = { + loading: , + success: , + failed: , +}; + +export default function PreferenceSettings({ user }: { user: UserType }) { + return ( +
+ +
+ ); +} + +function BooleanPreference({ + user, + preferenceId, + header, + description, +}: { + user: UserType; + preferenceId: string; + header: string; + description: string; +}) { + const [value, setValue] = useState( + (user.preferences as any)?.[preferenceId] ?? false + ); + const [status, setStatus] = useState< + "loading" | "success" | "failed" | undefined + >(undefined); + + const handleToggle = async (checked: boolean) => { + setValue(checked); + setStatus("loading"); + + try { + await request.post("/api/user/update-preference", { + data: { + [preferenceId]: checked, + }, + }); + setStatus("success"); + } catch (error) { + console.error("Failed to update preference:", error); + setValue(!checked); + setStatus("failed"); + } + }; + + return ( + + {status && statusIcons[status]} + handleToggle(checked)} + disabled={status === "loading"} + /> + + ); +} + +function Preference({ + header, + description, + inline, + children, +}: { + header: string; + description: string; + inline?: boolean; + children: ReactNode; +}) { + return ( +
+
+

+ {header} +

+

+ {description} +

+
+
{children}
+
+ ); +} diff --git a/src/components/oneko-kitty.tsx b/src/components/oneko-kitty.tsx new file mode 100644 index 0000000..37043e9 --- /dev/null +++ b/src/components/oneko-kitty.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useIsScreenSize } from "@/hooks/use-mobile"; +import { useEffect, useRef } from "react"; + +type SpriteName = + | "idle" + | "alert" + | "scratchSelf" + | "scratchWallN" + | "scratchWallS" + | "scratchWallE" + | "scratchWallW" + | "tired" + | "sleeping" + | "N" + | "NE" + | "E" + | "SE" + | "S" + | "SW" + | "W" + | "NW"; + +const spriteSets: Record = { + idle: [[-3, -3]], + alert: [[-7, -3]], + scratchSelf: [ + [-5, 0], + [-6, 0], + [-7, 0], + ], + scratchWallN: [ + [0, 0], + [0, -1], + ], + scratchWallS: [ + [-7, -1], + [-6, -2], + ], + scratchWallE: [ + [-2, -2], + [-2, -3], + ], + scratchWallW: [ + [-4, 0], + [-4, -1], + ], + tired: [[-3, -2]], + sleeping: [ + [-2, 0], + [-2, -1], + ], + N: [ + [-1, -2], + [-1, -3], + ], + NE: [ + [0, -2], + [0, -3], + ], + E: [ + [-3, 0], + [-3, -1], + ], + SE: [ + [-5, -1], + [-5, -2], + ], + S: [ + [-6, -3], + [-7, -2], + ], + SW: [ + [-5, -3], + [-6, -1], + ], + W: [ + [-4, -2], + [-4, -3], + ], + NW: [ + [-1, 0], + [-1, -1], + ], +}; + +export default function OnekoKitty() { + const showKitty = !useIsScreenSize(); + const nekoElRef = useRef(null); + + function init() { + // Get initial mouse position from current cursor location + const initialMousePos = { x: 0, y: 0 }; + const mouseEvent = (e: MouseEvent) => { + initialMousePos.x = e.clientX; + initialMousePos.y = e.clientY; + document.removeEventListener("mousemove", mouseEvent); + createKitty(); + }; + document.addEventListener("mousemove", mouseEvent); + + function createKitty() { + const nekoEl = document.createElement("div"); + nekoElRef.current = nekoEl; + nekoEl.id = "oneko"; + nekoEl.ariaHidden = "true"; + nekoEl.style.width = "32px"; + nekoEl.style.height = "32px"; + nekoEl.style.position = "fixed"; + nekoEl.style.pointerEvents = "none"; + nekoEl.style.imageRendering = "pixelated"; + + // Initialize position at mouse coordinates + nekoPosX = initialMousePos.x; + nekoPosY = initialMousePos.y; + mousePosX = initialMousePos.x; + mousePosY = initialMousePos.y; + + nekoEl.style.left = `${nekoPosX - 16}px`; + nekoEl.style.top = `${nekoPosY - 16}px`; + nekoEl.style.zIndex = "2147483647"; + + let nekoFile = "/oneko.gif"; + const curScript = document.currentScript; + if (curScript?.dataset.cat) { + nekoFile = curScript.dataset.cat; + } + nekoEl.style.backgroundImage = `url(${nekoFile})`; + + document.body.appendChild(nekoEl); + + document.addEventListener("mousemove", function (event) { + mousePosX = event.clientX; + mousePosY = event.clientY; + }); + + window.requestAnimationFrame(onAnimationFrame); + } + } + + useEffect(() => { + if (showKitty === undefined || !showKitty) { + cleanup(); + return; + } + + init(); + + return cleanup; + }, [showKitty]); + + function cleanup() { + if (nekoElRef.current !== undefined && nekoElRef.current?.isConnected) { + document.body.removeChild(nekoElRef.current); + } + nekoElRef.current = null; + } + + let lastFrameTimestamp: number | null = null; + + function onAnimationFrame(timestamp: number) { + if (!nekoElRef.current) { + return; + } + if (!lastFrameTimestamp) { + lastFrameTimestamp = timestamp; + } + if (timestamp - lastFrameTimestamp > 100) { + lastFrameTimestamp = timestamp; + frame(); + } + window.requestAnimationFrame(onAnimationFrame); + } + + function setSprite(name: SpriteName, frame: number) { + const sprite = spriteSets[name][frame % spriteSets[name].length]; + if (!sprite) return; + nekoElRef.current!.style.backgroundPosition = `${(sprite[0] ?? 0) * 32}px ${(sprite[1] ?? 0) * 32}px`; + } + + function resetIdleAnimation() { + idleAnimation = undefined; + idleAnimationFrame = 0; + } + + let idleTime = 0; + let idleAnimation: string | undefined = undefined; + let idleAnimationFrame = 0; + const nekoSpeed = 10; + + function idle() { + idleTime += 1; + + if ( + idleTime > 10 && + Math.floor(Math.random() * 200) === 0 && + idleAnimation == null + ) { + const availableIdleAnimations = ["sleeping", "scratchSelf"]; + if (nekoPosX < 32) { + availableIdleAnimations.push("scratchWallW"); + } + if (nekoPosY < 32) { + availableIdleAnimations.push("scratchWallN"); + } + if (nekoPosX > window.innerWidth - 32) { + availableIdleAnimations.push("scratchWallE"); + } + if (nekoPosY > window.innerHeight - 32) { + availableIdleAnimations.push("scratchWallS"); + } + idleAnimation = + availableIdleAnimations[ + Math.floor(Math.random() * availableIdleAnimations.length) + ]; + } + + switch (idleAnimation) { + case "sleeping": + if (idleAnimationFrame < 8) { + setSprite("tired", 0); + break; + } + setSprite("sleeping", Math.floor(idleAnimationFrame / 4)); + if (idleAnimationFrame > 192) { + resetIdleAnimation(); + } + break; + case "scratchWallN": + case "scratchWallS": + case "scratchWallE": + case "scratchWallW": + case "scratchSelf": + setSprite(idleAnimation, idleAnimationFrame); + if (idleAnimationFrame > 9) { + resetIdleAnimation(); + } + break; + default: + setSprite("idle", 0); + return; + } + idleAnimationFrame += 1; + } + + let frameCount = 0; + let nekoPosX = 32; + let nekoPosY = 32; + let mousePosX = 0; + let mousePosY = 0; + + function frame() { + frameCount += 1; + const diffX = nekoPosX - mousePosX; + const diffY = nekoPosY - mousePosY; + const distance = Math.sqrt(diffX ** 2 + diffY ** 2); + + if (distance < nekoSpeed || distance < 48) { + idle(); + return; + } + + idleAnimation = undefined; + idleAnimationFrame = 0; + + if (idleTime > 1) { + setSprite("alert", 0); + idleTime = Math.min(idleTime, 7); + idleTime -= 1; + return; + } + + let direction = ""; + direction = diffY / distance > 0.5 ? "N" : ""; + direction += diffY / distance < -0.5 ? "S" : ""; + direction += diffX / distance > 0.5 ? "W" : ""; + direction += diffX / distance < -0.5 ? "E" : ""; + setSprite(direction as SpriteName, frameCount); + + nekoPosX -= (diffX / distance) * nekoSpeed; + nekoPosY -= (diffY / distance) * nekoSpeed; + + nekoPosX = Math.min(Math.max(16, nekoPosX), window.innerWidth - 16); + nekoPosY = Math.min(Math.max(16, nekoPosY), window.innerHeight - 16); + + nekoElRef.current!.style.left = `${nekoPosX - 16}px`; + nekoElRef.current!.style.top = `${nekoPosY - 16}px`; + } + + return null; +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..1cbc902 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { cn } from "@/lib/utils/utils"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { CheckIcon } from "lucide-react"; +import * as React from "react"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/src/components/user/profile-button.tsx b/src/components/user/profile-button.tsx index 89fd590..3b885e0 100644 --- a/src/components/user/profile-button.tsx +++ b/src/components/user/profile-button.tsx @@ -2,37 +2,37 @@ import AvatarInitials from "@/components/avatar-initials"; import { getUser } from "@/lib/helpers/user"; import Link from "next/link"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "../ui/dropdown-menu"; import LogoutButton from "./logout-button"; export default async function ProfileButton() { - const user = await getUser(); - return ( - - - - - @{user.username} - - - - - - Dashboard - - - - - - - ); + const user = await getUser(); + return ( + + + + + @{user.username} + + + + + + Dashboard + + + + + + + ); } diff --git a/src/lib/db/schemas/auth-schema.ts b/src/lib/db/schemas/auth-schema.ts index 5a9d797..c4407c0 100644 --- a/src/lib/db/schemas/auth-schema.ts +++ b/src/lib/db/schemas/auth-schema.ts @@ -1,60 +1,63 @@ +import { PreferencesType } from "@/lib/db/schemas/preference"; import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; export const users = pgTable("users", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("email_verified").notNull(), - image: text("image"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), - username: text("username").unique(), - role: text("role"), - banned: boolean("banned"), - banReason: text("ban_reason"), - banExpires: timestamp("ban_expires"), - uploadToken: text("upload_token"), + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").notNull(), + image: text("image"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + username: text("username").unique(), + role: text("role"), + banned: boolean("banned"), + banReason: text("ban_reason"), + banExpires: timestamp("ban_expires"), + uploadToken: text("upload_token"), }); export const sessions = pgTable("sessions", { - id: text("id").primaryKey(), - expiresAt: timestamp("expires_at").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - impersonatedBy: text("impersonated_by"), + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + impersonatedBy: text("impersonated_by"), }); export const accounts = pgTable("accounts", { - id: text("id").primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: timestamp("access_token_expires_at"), - refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("created_at").notNull(), - updatedAt: timestamp("updated_at").notNull(), + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").notNull(), + updatedAt: timestamp("updated_at").notNull(), }); export const verifications = pgTable("verifications", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expires_at").notNull(), - createdAt: timestamp("created_at"), - updatedAt: timestamp("updated_at"), + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at"), + updatedAt: timestamp("updated_at"), }); -export type UserType = typeof users.$inferSelect; +export type UserType = typeof users.$inferSelect & { + preferences: PreferencesType | undefined; +}; diff --git a/src/lib/db/schemas/preference.ts b/src/lib/db/schemas/preference.ts new file mode 100644 index 0000000..492fde7 --- /dev/null +++ b/src/lib/db/schemas/preference.ts @@ -0,0 +1,16 @@ +import { boolean, integer, pgTable, serial, text } from "drizzle-orm/pg-core"; +import { users } from "./auth-schema"; + +export const preferencesTable = pgTable("preferences", { + id: serial("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id) + .unique(), + + // Preferences + showKitty: boolean("show_kitty"), + webhookUrl: text("webhook_url"), +}); + +export type PreferencesType = typeof preferencesTable.$inferSelect; diff --git a/src/lib/helpers/user.ts b/src/lib/helpers/user.ts index 1bc16f2..0f54b70 100644 --- a/src/lib/helpers/user.ts +++ b/src/lib/helpers/user.ts @@ -1,12 +1,13 @@ import { users, UserType } from "@/lib/db/schemas/auth-schema"; +import { getUserPreferences } from "@/lib/preference"; import { AnyColumn, asc, count, desc, eq } from "drizzle-orm"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { auth } from "../auth"; import { db } from "../db/drizzle"; import { fileTable } from "../db/schemas/file"; -import { randomString } from "../utils/utils"; import { thumbnailTable } from "../db/schemas/thumbnail"; +import { randomString } from "../utils/utils"; /** * Gets a user by their upload token @@ -15,7 +16,7 @@ import { thumbnailTable } from "../db/schemas/thumbnail"; * @returns the user, or undefined if not found */ export async function getUserByUploadToken(token: string) { - return (await db.select().from(users).where(eq(users.uploadToken, token)))[0]; + return (await db.select().from(users).where(eq(users.uploadToken, token)))[0]; } /** @@ -25,7 +26,7 @@ export async function getUserByUploadToken(token: string) { * @returns the user, or undefined if not found */ export async function getUserById(id: string) { - return (await db.select().from(users).where(eq(users.id, id)))[0]; + return (await db.select().from(users).where(eq(users.id, id)))[0]; } /** @@ -35,7 +36,7 @@ export async function getUserById(id: string) { * @returns the user, or undefined if not found */ export async function getUserByName(username: string) { - return (await db.select().from(users).where(eq(users.username, username)))[0]; + return (await db.select().from(users).where(eq(users.username, username)))[0]; } /** @@ -46,40 +47,40 @@ export async function getUserByName(username: string) { * @returns the files for the user. */ export async function getUserFiles( - id: string, - options?: { - sort?: { - key: keyof typeof fileTable.$inferSelect; - direction: "asc" | "desc"; - }; - limit?: number; - offset?: number; - } + id: string, + options?: { + sort?: { + key: keyof typeof fileTable.$inferSelect; + direction: "asc" | "desc"; + }; + limit?: number; + offset?: number; + } ) { - const query = db.select().from(fileTable).where(eq(fileTable.userId, id)); + const query = db.select().from(fileTable).where(eq(fileTable.userId, id)); - if (options?.limit) { - query.limit(options.limit); - } + if (options?.limit) { + query.limit(options.limit); + } - if (options?.offset) { - query.offset(options.offset); - } + if (options?.offset) { + query.offset(options.offset); + } - if (options?.sort) { - const { key, direction } = options.sort; + if (options?.sort) { + const { key, direction } = options.sort; - // Ensure the key is a valid column from fileTable - const column = fileTable[key as keyof typeof fileTable] as AnyColumn; - if (!column) { - throw new Error(`Column "${key}" on ${fileTable._.name} was not found.`); - } + // Ensure the key is a valid column from fileTable + const column = fileTable[key as keyof typeof fileTable] as AnyColumn; + if (!column) { + throw new Error(`Column "${key}" on ${fileTable._.name} was not found.`); + } - // Apply sorting based on the direction - query.orderBy(direction === "asc" ? asc(column) : desc(column)); - } + // Apply sorting based on the direction + query.orderBy(direction === "asc" ? asc(column) : desc(column)); + } - return await query; + return await query; } /** @@ -89,10 +90,10 @@ export async function getUserFiles( * @returns the thumbnails for the user. */ export async function getUserThumbnails(id: string) { - return await db - .select() - .from(thumbnailTable) - .where(eq(thumbnailTable.userId, id)); + return await db + .select() + .from(thumbnailTable) + .where(eq(thumbnailTable.userId, id)); } /** @@ -103,11 +104,11 @@ export async function getUserThumbnails(id: string) { * @returns the amount of files uploaded */ export async function getUserFilesCount(id: string) { - const query = await db - .select({ count: count() }) - .from(fileTable) - .where(eq(fileTable.userId, id)); - return query[0].count ?? undefined; + const query = await db + .select({ count: count() }) + .from(fileTable) + .where(eq(fileTable.userId, id)); + return query[0].count ?? undefined; } /** @@ -117,7 +118,7 @@ export async function getUserFilesCount(id: string) { * @param values the values to update */ export async function updateUser(id: string, values: Record) { - await db.update(users).set(values).where(eq(users.id, id)); + await db.update(users).set(values).where(eq(users.id, id)); } /** @@ -126,7 +127,7 @@ export async function updateUser(id: string, values: Record) { * @returns the upload token */ export function generateUploadToken() { - return randomString(32); + return randomString(32); } /** @@ -136,12 +137,15 @@ export function generateUploadToken() { * @returns the current user (wtf is the type? x.x) */ export async function getUser(): Promise { - const session = await auth.api.getSession({ - headers: await headers(), - }); - // This shouldn't happen - if (!session) { - redirect("/"); - } - return session.user as UserType; + const session = await auth.api.getSession({ + headers: await headers(), + }); + // This shouldn't happen + if (!session) { + redirect("/"); + } + return { + ...(session.user as UserType), + preferences: await getUserPreferences(session.user.id), + }; } diff --git a/src/lib/preference.ts b/src/lib/preference.ts new file mode 100644 index 0000000..7666fd3 --- /dev/null +++ b/src/lib/preference.ts @@ -0,0 +1,52 @@ +import { db } from "@/lib/db/drizzle"; +import { preferencesTable, PreferencesType } from "@/lib/db/schemas/preference"; +import { AppCache, fetchWithCache } from "@/lib/utils/cache"; +import { eq } from "drizzle-orm"; + +const userPreferencesCache = new AppCache({ + ttl: 1000 * 60 * 60, // 1 hour + checkInterval: 1000 * 60 * 60, // 1 hour +}); + +/** + * Get the preferences for a user. + * + * @param userId the user's id + * @returns the user's preferences + */ +export async function getUserPreferences( + userId: string +): Promise { + return await fetchWithCache( + userPreferencesCache, + `user-preferences:${userId}`, + async () => + ( + await db + .select() + .from(preferencesTable) + .where(eq(preferencesTable.userId, userId)) + )?.[0] + ); +} + +/** + * Updates the preferences for a user. + * + * @param userId the user's id + * @param updates an object containing the preferences to update + */ +export async function updateUserPreferences( + userId: string, + updates: Partial +) { + // First update the database, and then invalidate the in-memory cache + await db + .insert(preferencesTable) + .values({ userId: userId, ...updates }) + .onConflictDoUpdate({ + target: preferencesTable.userId, + set: updates, + }); + userPreferencesCache.remove(`user-preferences:${userId}`); +} diff --git a/src/lib/utils/cache.ts b/src/lib/utils/cache.ts new file mode 100644 index 0000000..23b9792 --- /dev/null +++ b/src/lib/utils/cache.ts @@ -0,0 +1,248 @@ +type DebugOptions = { + added?: boolean; + removed?: boolean; + fetched?: boolean; + expired?: boolean; + missed?: boolean; +}; + +export type CacheStatistics = { + /** + * The size in bytes of the cache + */ + size: number; + + /** + * The number of objects in the cache + */ + keys: number; + + /** + * The number of cache hits + */ + hits: number; + + /** + * The number of cache misses + */ + misses: number; + + /** + * The number of expired objects + */ + expired: number; + + /** + * The percentage of cache hits + */ + hitPercentage: number; +}; + +type CacheOptions = { + /** + * The time (in ms) the cached object will be valid for + */ + ttl?: number; + + /** + * How often to check for expired objects + */ + checkInterval?: number; + + /** + * Enable debug messages + */ + debug?: DebugOptions; +}; + +type CachedObject = { + /** + * The cached object + */ + value: any; + + /** + * The timestamp the object was cached + */ + timestamp: number; +}; + +export class AppCache { + /** + * The time the cached object will be valid for + * @private + */ + private readonly ttl: number | undefined; + + /** + * How often to check for expired objects + * @private + */ + private readonly checkInterval: number | undefined; + + /** + * Enable debug messages + * @private + */ + private readonly debug: DebugOptions; + + /** + * The number of cache hits + * @private + */ + private cacheHits: number = 0; + + /** + * The number of cache misses + * @private + */ + private cacheMisses: number = 0; + + /** + * The number of expired objects + * @private + */ + private expired: number = 0; + + /** + * The objects that have been cached + * @private + */ + private cache = new Map(); + + constructor({ ttl, checkInterval, debug }: CacheOptions) { + this.ttl = ttl; + this.checkInterval = checkInterval || this.ttl ? 1000 * 60 : undefined; // 1 minute + this.debug = debug ?? {}; + + if (this.ttl !== undefined && this.checkInterval !== undefined) { + setInterval(() => { + const before = this.cache.size; + for (const [key, value] of this.cache.entries()) { + if (value.timestamp + this.ttl! > Date.now()) { + continue; + } + this.expired++; + this.remove(key); + } + if (this.debug.expired) { + console.log( + `Expired ${before - this.cache.size} objects from cache (before: ${before}, after: ${this.cache.size})` + ); + } + }, this.checkInterval); + } + } + + /** + * Gets an object from the cache + * + * @param key the cache key for the object + */ + public get(key: string): T | undefined { + const cachedObject = this.cache.get(key); + if (cachedObject === undefined) { + if (this.debug.missed) { + console.log( + `Cache miss for key: ${key}, total misses: ${this.cacheMisses}` + ); + } + this.cacheMisses++; + return undefined; + } + if (this.debug.fetched) { + console.log( + `Retrieved ${key} from cache, total hits: ${this.cacheHits}` + ); + } + this.cacheHits++; + return cachedObject.value as T; + } + + /** + * Sets an object in the cache + * + * @param key the cache key + * @param value the object + */ + public set(key: string, value: T): void { + this.cache.set(key, { + value, + timestamp: Date.now(), + }); + + if (this.debug.added) { + console.log( + `Inserted ${key} into cache, total keys: ${this.cache.size}, total hits: ${this.cacheHits}, total misses: ${this.cacheMisses}` + ); + } + } + + /** + * Checks if an object is in the cache + * + * @param key the cache key + */ + public has(key: string): boolean { + return this.cache.has(key); + } + + /** + * Removes an object from the cache + * + * @param key the cache key + */ + public remove(key: string): void { + this.cache.delete(key); + + if (this.debug.removed) { + console.log(`Removed ${key} from cache`); + } + } + + /** + * Gets the cache statistics + */ + public getStatistics(): CacheStatistics { + return { + size: Buffer.byteLength( + JSON.stringify(Array.from(this.cache.entries())), + "utf-8" + ), + keys: this.cache.size, + hits: this.cacheHits, + misses: this.cacheMisses, + expired: this.expired, + hitPercentage: + this.cacheHits === 0 + ? 0 + : (this.cacheHits / (this.cacheHits + this.cacheMisses)) * + 100, + }; + } +} + +/** + * Fetches data with caching. + * + * @param cache the cache to fetch from + * @param cacheKey The key used for caching. + * @param fetchFn The function to fetch data if it's not in cache. + */ +export async function fetchWithCache( + cache: AppCache, + cacheKey: string, + fetchFn: () => Promise +): Promise { + if (cache == undefined) { + throw new Error(`Cache is not defined`); + } + if (cache.has(cacheKey)) { + return cache.get(cacheKey)!; + } + const data = await fetchFn(); + if (data) { + cache.set(cacheKey, data); + } + return data; +}