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 0000000..a009c2c
Binary files /dev/null and b/public/oneko.gif differ
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 (
-
- );
+ const user = await getUser();
+ return (
+
+
+
+
+ {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;
+}