feat(chat): enhance chat functionality with file attachments

- Added file attachment support in chat messages, allowing users to upload and send files.
- Implemented a new API endpoint to retrieve message attachments.
- Updated chat components to display attachments and handle file uploads.
- Improved user experience with loading indicators and toast notifications for file uploads.
- Refactored chat bottom bar to integrate file upload functionality seamlessly.
- Enhanced chat list to show attachments associated with messages.
- Updated database schema to support attachments in chat messages.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Marco Pedone 2026-04-28 14:05:54 +02:00
parent 9886e5042a
commit ff722eb2a7
13 changed files with 704 additions and 673 deletions

View file

@ -0,0 +1,42 @@
CREATE TABLE IF NOT EXISTS public.messages_attachments
(
"messageId" uuid NOT NULL,
"userStorageId" uuid NOT NULL
);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'messages_attachments_pkey'
AND conrelid = 'public.messages_attachments'::regclass
) THEN
ALTER TABLE public.messages_attachments
ADD CONSTRAINT messages_attachments_pkey PRIMARY KEY ("messageId", "userStorageId");
END IF;
END $$;
DO $$ BEGIN IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'msg_attach_msgid'
AND conrelid = 'public.messages_attachments'::regclass
) THEN
ALTER TABLE public.messages_attachments
ADD CONSTRAINT msg_attach_msgid
FOREIGN KEY ("messageId") REFERENCES public.messages (messageid)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;
DO $$ BEGIN IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'msg_attach_storageid'
AND conrelid = 'public.messages_attachments'::regclass
) THEN
ALTER TABLE public.messages_attachments
ADD CONSTRAINT msg_attach_storageid
FOREIGN KEY ("userStorageId") REFERENCES public.users_storage (user_storage_id)
ON UPDATE CASCADE ON DELETE CASCADE;
END IF;
END $$;

View file

@ -2,6 +2,46 @@ const { makeKyselyHook } = require("kanel-kysely");
const { makePgTsGenerator } = require("kanel");
const ignoredTables = ["schema_migrations"];
/**
* Allows for injection of custom types from the mapping of `table.field`
* @param {import("kanel").TypeMap} mappings
* @returns {import("kanel").PreRenderHook}
*/
const specificTypes = (mappings) => {
/** @type {import("kanel").PreRenderHook} */
return async (outputAcc, instantiatedConfig) => {
const output = { ...outputAcc };
for (const path of Object.keys(output)) {
const file = outputAcc[path];
const declarations = file.declarations.map((decl) => {
const declaration = { ...decl };
if (declaration.declarationType === "interface") {
for (const field of declaration.properties) {
const key = `${declaration.name}.${field.name}`;
if (mappings[key]) {
field.typeName = mappings[key].name;
declaration.typeImports = [
...(declaration.typeImports || []),
...mappings[key].typeImports,
];
}
}
}
return declaration;
});
output[path] = {
...file,
declarations,
};
}
return output;
};
}
/** @type {import('kanel').Config} */
module.exports = {
connection: {
@ -19,11 +59,25 @@ module.exports = {
"pg_catalog.bpchar": "string",
'pg_catalog.numeric': 'number',
//"pg_catalog.json": "string",
},
preRenderHooks: [makeKyselyHook()],
preRenderHooks: [
specificTypes({
"Annunci.caratteristiche": {
name: "Caratteristiche",
typeImports: [{ name: "Caratteristiche", path: "src/utils/kanel-types.ts", importAsType: true, isAbsolute: false, isDefault: false }],
},
}),
makeKyselyHook(),
],
}),
],
preDeleteOutputFolder: true,
typescriptConfig: {
enumStyle: "enum",
},
};

View file

@ -26,7 +26,7 @@
"@stripe/stripe-js": "^8.8.0",
"@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/postcss": "^4.2.2",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.19",
"@tanstack/react-table": "^8.21.3",
@ -61,7 +61,7 @@
"nextjs-progressbar": "^0.0.16",
"nodemailer": "^8.0.5",
"nodemailer-html-to-text": "^3.2.0",
"nuqs": "^2.8.6",
"nuqs": "^2.8.9",
"papaparse": "^5.5.3",
"pdf-lib": "^1.17.1",
"pg": "^8.19.0",
@ -71,7 +71,7 @@
"react-colorful": "^5.6.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.3",
"react-hook-form": "^7.71.1",
"react-hook-form": "^7.72.1",
"react-hot-toast": "^2.5.2",
"react-is": "^19.2.3",
"react-leaflet": "^5.0.0",
@ -86,10 +86,10 @@
"superjson": "^2.2.6",
"tailwind-merge": "^3.5.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.2",
"tunnel-rat": "^0.1.2",
"tw-animate-css": "^1.4.0",
"type-fest": "^5.4.1",
"type-fest": "^5.5.0",
"uuid": "^13.0.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
@ -1804,6 +1804,8 @@
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@ -5193,6 +5195,29 @@
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
@ -5357,43 +5382,47 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
"integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"enhanced-resolve": "^5.18.3",
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.18"
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.18",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
"integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
"license": "MIT",
"engines": {
"node": ">= 10"
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
"@tailwindcss/oxide-darwin-x64": "4.1.18",
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
"@tailwindcss/oxide-android-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-arm64": "4.2.4",
"@tailwindcss/oxide-darwin-x64": "4.2.4",
"@tailwindcss/oxide-freebsd-x64": "4.2.4",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
"@tailwindcss/oxide-linux-x64-musl": "4.2.4",
"@tailwindcss/oxide-wasm32-wasi": "4.2.4",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
"integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
"cpu": [
"arm64"
],
@ -5403,13 +5432,13 @@
"android"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
"integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
"cpu": [
"arm64"
],
@ -5419,13 +5448,13 @@
"darwin"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
"integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
"cpu": [
"x64"
],
@ -5435,13 +5464,13 @@
"darwin"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
"integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
"cpu": [
"x64"
],
@ -5451,13 +5480,13 @@
"freebsd"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
"integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
"cpu": [
"arm"
],
@ -5467,13 +5496,13 @@
"linux"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
"integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
"cpu": [
"arm64"
],
@ -5483,13 +5512,13 @@
"linux"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
"integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
"cpu": [
"arm64"
],
@ -5499,13 +5528,13 @@
"linux"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
"integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
"cpu": [
"x64"
],
@ -5515,13 +5544,13 @@
"linux"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
"integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
"cpu": [
"x64"
],
@ -5531,13 +5560,13 @@
"linux"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
"integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -5552,21 +5581,21 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.4.0"
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
"integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
"cpu": [
"arm64"
],
@ -5576,11 +5605,13 @@
"win32"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.18",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
"integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
"cpu": [
"x64"
],
@ -5590,18 +5621,20 @@
"win32"
],
"engines": {
"node": ">= 10"
"node": ">= 20"
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.18",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
"integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.18",
"@tailwindcss/oxide": "4.1.18",
"postcss": "^8.4.41",
"tailwindcss": "4.1.18"
"@tailwindcss/node": "4.2.4",
"@tailwindcss/oxide": "4.2.4",
"postcss": "^8.5.6",
"tailwindcss": "4.2.4"
}
},
"node_modules/@tailwindcss/typography": {
@ -7450,11 +7483,13 @@
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.4",
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
"integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.2.0"
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
@ -9237,7 +9272,9 @@
"license": "MIT"
},
"node_modules/lightningcss": {
"version": "1.30.2",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -9250,23 +9287,23 @@
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
@ -9284,9 +9321,9 @@
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
@ -9304,9 +9341,9 @@
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
@ -9324,9 +9361,9 @@
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
@ -9344,9 +9381,9 @@
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
@ -9364,9 +9401,9 @@
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
@ -9384,9 +9421,9 @@
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
@ -9404,9 +9441,9 @@
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
@ -9424,9 +9461,9 @@
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
@ -9444,9 +9481,9 @@
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
@ -9464,7 +9501,9 @@
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
@ -10201,7 +10240,9 @@
"license": "MIT"
},
"node_modules/nuqs": {
"version": "2.8.6",
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz",
"integrity": "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.0.0"
@ -11470,7 +11511,9 @@
}
},
"node_modules/react-hook-form": {
"version": "7.71.1",
"version": "7.74.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz",
"integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==",
"license": "MIT",
"peer": true,
"engines": {
@ -12668,12 +12711,16 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
"integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"license": "MIT",
"engines": {
"node": ">=6"
@ -12873,7 +12920,9 @@
}
},
"node_modules/type-fest": {
"version": "5.4.1",
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz",
"integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==",
"license": "(MIT OR CC0-1.0)",
"dependencies": {
"tagged-tag": "^1.0.0"
@ -13239,263 +13288,6 @@
"vite": "*"
}
},
"node_modules/vite/node_modules/lightningcss": {
"version": "1.32.0",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/vite/node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/vitest": {
"version": "4.1.4",
"dev": true,

View file

@ -1,185 +1,65 @@
import { differenceInDays, differenceInMinutes, format } from "date-fns";
import { Paperclip } from "lucide-react";
import { Download } from "lucide-react";
import Link from "next/link";
import { ExtIcon } from "~/components/area-riservata/allegati";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import { LoadingPage } from "~/components/loading";
import { Button, buttonVariants } from "~/components/ui/button";
import { UploadComponent } from "~/components/upload_modal";
import { cn } from "~/lib/utils";
import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider";
import { Button } from "~/components/ui/button";
import { getStorageUrl } from "~/lib/storage_utils";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import { api } from "~/utils/api";
export const ChatAttachments = () => {
const { session, userinfo } = useChatContext();
const { t } = useTranslation();
const {
data: attachments,
isLoading: attachmentsLoading,
refetch,
} = api.storage.retrieveUserFileData.useQuery(
{ userId: userinfo.id },
export const ChatAttachments = ({
messageId,
}: {
messageId: MessagesMessageid;
}) => {
const { data, isLoading } = api.chat.getMessageAttachments.useQuery(
{ messageId },
{ refetchOnMount: true },
);
return (
<Credenza onOpenChange={(o) => o && refetch()}>
<CredenzaTrigger asChild>
<Button
aria-label="Allegati Chat"
className="relative size-9"
size="icon"
variant="ghost"
>
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 font-bold text-white text-xs">
{attachments.length}
</span>
)}
<Paperclip className="text-muted-foreground" size={20} />
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] md:max-w-xl">
<CredenzaHeader>
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
<CredenzaDescription className="sr-only">
Chat Attachments
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-5 overflow-auto pb-5 md:px-1">
<UploadComponent isAdmin={session.isAdmin} userId={userinfo.id} />
if (isLoading) {
return <LoadingPage />;
}
if (!data || data.length === 0) {
return null;
}
<div className="h-auto max-h-160 max-w-lg overflow-auto rounded-lg">
{attachmentsLoading ? (
<LoadingPage />
) : (
<>
{!attachments || attachments.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-center font-semibold text-gray-500 text-lg">
{t.allegati.nessun_allegato}
</p>
return (
<div className="mt-1 flex flex-col items-end gap-1">
{data.map((file) => (
<div
className="flex w-fit items-center justify-end gap-2 rounded-md bg-secondary p-2 text-sm hover:bg-muted/50"
key={file.id}
>
<div className="flex grow basis-full items-center gap-2 md:col-span-8">
<div className="shrink-0">
<ExtIcon mimeType={file.mimeType} />
</div>
) : (
<ul className="px-2">
<h3 className="font-semibold text-gray-700 text-lg">
{t.allegati.allegati_recenti}
</h3>
{attachments?.map((file, idx) => {
if (idx >= 3) return null;
return (
<li
className="flex flex-row items-center justify-between border-b py-1"
key={`${file.originalName}-${
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
idx
}`}
>
<Link
aria-label="Visualizza Allegato"
aria-label="Allegato"
className="max-w-md truncate font-medium text-foreground hover:underline"
href={`/area-riservata/allegato-view/${file.id}`}
onTouchStart={() => console.log("touching")}
target="_blank"
>
<Button
className="h-8 truncate p-0 text-base has-[>svg]:px-0"
variant="link"
>
<ExtIcon mimeType={file.mimeType} />
<span className="max-w-[60vw] truncate">
{file.originalName}
</span>
</Link>
</div>
<div className="flex justify-end gap-1 md:col-span-1">
<Link
href={getStorageUrl({
storageId: file.id,
params: { mode: "download" },
})}
>
<Button className="flex size-8 gap-1 text-sm" size="icon">
<Download className="size-4" />
</Button>
</Link>
<span
className="text-muted-foreground text-xs"
key={new Date().toString()}
>
{TimeSince(new Date(file.uploadedAt))}
</span>
</li>
);
})}
</ul>
)}
</>
)}
</div>
<Link
aria-label="Visualizza Allegati"
className={cn(
buttonVariants({
variant: "outline",
}),
"relative w-full",
)}
href="/area-riservata/allegati"
>
<Paperclip /> {t.allegati.i_tuoi_allegati}
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-red-600 text-white text-xs">
{attachments.length}
</span>
)}
</Link>
</CredenzaBody>
<CredenzaFooter className="flex flex-col items-center justify-between gap-2">
<CredenzaClose asChild>
<Button className="w-full">{t.chiudi}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</div>
))}
</div>
);
};
const TimeSince = (date: Date) => {
const minsSince = Math.abs(differenceInMinutes(date, new Date()));
const daysSince = Math.abs(differenceInDays(date, new Date()));
const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120];
if (daysSince === 0) {
if (minsSince < 1) {
return "ora";
}
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
? curr
: prev;
});
if (closestToNow === 60) {
return "~1 ora fa";
}
if (closestToNow === 120 && minsSince < 121) {
return "~2 ore fa";
}
return `~${closestToNow} min fa`;
}
if (daysSince === 1) {
return "ieri";
}
if (daysSince < 30) {
return `${daysSince} giorni fa`;
}
return format(date, "dd/MM/yyyy");
};

View file

@ -1,18 +1,29 @@
import {
ArrowDown,
Paperclip,
SendHorizontal,
SmilePlusIcon,
ThumbsUp,
X,
} from "lucide-react";
import { type ChangeEvent, useCallback, useEffect, useState } from "react";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import toast from "react-hot-toast";
import { AutosizeTextarea } from "~/components/custom_ui/autoResizeTextArea";
import {
EmojiPicker,
EmojiPickerContent,
EmojiPickerSearch,
} from "~/components/custom_ui/emoji-picker";
import {
FileUpload,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from "~/components/custom_ui/fileUpload";
import { LoadingSpinner } from "~/components/spinner";
import { Button } from "~/components/ui/button";
import {
Popover,
@ -20,9 +31,11 @@ import {
PopoverTrigger,
} from "~/components/ui/popover";
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
import { uploadHandler } from "~/hooks/storageClienSideHooks";
import { useMediaQuery } from "~/hooks/use-media-query";
import { useChatContext } from "~/providers/ChatProvider";
import { useTranslation } from "~/providers/I18nProvider";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
import { api } from "~/utils/api";
export default function ChatBottombar() {
@ -31,14 +44,17 @@ export default function ChatBottombar() {
session,
inputRef,
replyTrg,
userinfo,
setReplyTrg,
messagesContainerRef,
} = useChatContext();
const { locale } = useTranslation();
const { locale, t } = useTranslation();
const [message, setMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [files, setFiles] = useState<File[]>([]);
const [isFocused, setIsFocused] = useState(false);
const { mutate: addMutation } = api.chatSSE.sendMessage.useMutation({
const { mutateAsync: addMutation } = api.chatSSE.sendMessage.useMutation({
onError: (err) => {
console.error("Failed to send message", err);
},
@ -51,26 +67,94 @@ export default function ChatBottombar() {
}
},
});
const utils = api.useUtils();
const { mutateAsync: setUserStorage } =
api.storage.addUserStorageEntry.useMutation({
onMutate: () => {
toast(t.allegati.allegato_caricato, {
icon: "📤",
toasterId: "uploading-toast",
});
},
onSettled: async () => {
await utils.storage.getAll.invalidate();
await utils.storage.retrieveUserFileData.invalidate({
userId: userinfo.id,
});
},
});
const uploadFiles = async () => {
if (files.length === 0) {
return [];
}
const uploadedFiles: {
storageId: string;
userStorageId: UsersStorageUserStorageId;
}[] = [];
const handleThumbsUp = () => {
addMutation({
try {
const from_admin = session.isAdmin;
const { status, attachments } = await uploadHandler({
files,
from_admin,
});
if (!status) {
toast.error(t.allegati.errore_caricamento, {
icon: "❌",
toasterId: "uploading-toast",
});
return [];
}
for (const storageId of attachments) {
const res = await setUserStorage({
storageId,
userId: userinfo.id,
from_admin,
});
if (!res) continue;
uploadedFiles.push({ storageId, userStorageId: res.user_storage_id });
}
return uploadedFiles;
} catch (error) {
toast.error(
error instanceof Error ? error.message : t.allegati.errore_sconosciuto,
{ icon: "❌", toasterId: "uploading-toast" },
);
return [];
} finally {
setFiles([]);
}
};
const handleThumbsUp = async () => {
setIsLoading(true);
const attachments = await uploadFiles();
await addMutation({
reply_to_id: replyTrg?.messageid || null,
chatId,
message: "👍",
sender: session.id,
attachments,
});
setIsLoading(false);
};
const handleSend = () => {
if (message.length !== 0) {
addMutation({
const handleSend = async () => {
setIsLoading(true);
const attachments = await uploadFiles();
await addMutation({
reply_to_id: replyTrg?.messageid || null,
chatId,
message: message.trim(),
sender: session.id,
attachments,
});
}
setIsLoading(false);
};
const isTypingMutation = useThrottledIsTypingMutation({
chatId,
username: session.username,
@ -112,17 +196,49 @@ export default function ChatBottombar() {
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [messagesContainerRef]);
return (
<div className="w-full">
<FileUpload
maxFiles={5}
maxSize={5 * 1024 * 1024}
multiple
onValueChange={setFiles}
value={files}
>
<div className="relative flex w-full flex-col border-t">
{showScrollDown && (
<Button
className="absolute -top-1/2 left-1/2 size-12 -translate-x-1/2 -translate-y-1/2 rounded-full opacity-80"
className="absolute -top-1/2 left-1/2 size-12 -translate-x-1/2 rounded-full opacity-80"
onClick={handleJumpToBottom}
variant="outline"
>
<ArrowDown className="size-6" />
</Button>
)}
<FileUploadList className="my-2 max-w-[calc(100vw-48px)] px-2">
{files.map((file, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <ok>
<FileUploadItem className="w-full p-2" key={index} value={file}>
<FileUploadItemPreview className="size-8" />
<FileUploadItemMetadata
//className="max-w-[60vw] sm:max-w-none"
size="sm"
/>
{isLoading ? (
<div className="flex items-center justify-center">
<LoadingSpinner className="size-6" />
</div>
) : (
<FileUploadItemDelete asChild>
<Button className="size-6" size="icon" variant="ghost">
<X className="size-4" />
</Button>
</FileUploadItemDelete>
)}
</FileUploadItem>
))}
</FileUploadList>
{replyTrg && (
<div className="p-2">
<div className="relative flex-1 rounded-lg border bg-muted p-2 text-sm">
@ -132,6 +248,7 @@ export default function ChatBottombar() {
</div>
<Button
className="absolute top-1 right-1"
disabled={isLoading}
onClick={() => setReplyTrg(null)}
size="icon"
variant="outline"
@ -142,9 +259,12 @@ export default function ChatBottombar() {
</div>
)}
<div className="flex min-h-18.5 w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments />
</div>
<FileUploadTrigger asChild>
<Button className="size-8 shrink-0" size="icon" variant="ghost">
<Paperclip className="size-4" />
</Button>
</FileUploadTrigger>
<div className="relative w-full">
<AutosizeTextarea
autoComplete="off"
@ -187,7 +307,9 @@ export default function ChatBottombar() {
}}
>
<EmojiPickerSearch
placeholder={locale === "en" ? "Search..." : "Cerca..."}
placeholder={
locale === "en" ? "Search..." : "Cerca..."
}
/>
<EmojiPickerContent />
</EmojiPicker>
@ -199,12 +321,17 @@ export default function ChatBottombar() {
<Button
aria-label="Send Message"
className="shrink-0"
disabled={isLoading}
onClick={
message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
message.length !== 0
? () => handleSend()
: () => handleThumbsUp()
}
variant="ghost"
>
{message.length !== 0 ? (
{isLoading ? (
<LoadingSpinner className="size-6" />
) : message.length !== 0 ? (
<SendHorizontal className="text-muted-foreground" size={20} />
) : (
<ThumbsUp className="text-muted-foreground" size={20} />
@ -212,5 +339,7 @@ export default function ChatBottombar() {
</Button>
</div>
</div>
</FileUpload>
</div>
);
}

View file

@ -10,6 +10,7 @@ import {
useState,
} from "react";
import toast from "react-hot-toast";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import {
ChatBubble,
ChatBubbleMessage,
@ -245,7 +246,7 @@ export const ChatList = () => {
key={message.messageid}
>
{!isPrevSameDay && (
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-muted-foreground text-xs">
<span className="mx-auto mt-1 rounded-md bg-primary px-2 py-1 text-center text-secondary text-xs">
{new Date(message.time).toLocaleDateString("it", {
day: "2-digit",
month: "2-digit",
@ -291,7 +292,7 @@ export const ChatList = () => {
<ChatBubble variant={isSender ? "sent" : "received"}>
<ChatBubbleMessage
className={cn(
"flex flex-wrap items-end justify-end gap-x-2 gap-y-1",
"flex flex-col items-end justify-end gap-x-2 gap-y-1",
isNextSameSender && "rounded-lg",
"",
)}
@ -328,6 +329,9 @@ export const ChatList = () => {
</div>
</button>
)}
{message.has_attachments && (
<ChatAttachments messageId={message.messageid} />
)}
<span className="flex shrink break-all">
{message.message}
</span>

View file

@ -83,7 +83,18 @@ export default function ChatTopbar() {
</div>
</div>
<div>
<div className="flex items-center gap-2">
<Link
aria-label="Visualizza Allegati Utente"
className={cn(
buttonVariants({ variant: "outline" }),
"justify-start gap-2",
)}
href={`/area-riservata/admin/user-view/allegati/${userinfo.id}`}
>
<Paperclip /> Allegati
</Link>
{session.isAdmin && (
<AdminPopover
handleDeleteChat={handleDeleteChat}

View file

@ -1,3 +1,4 @@
import type { Caratteristiche } from '../../utils/kanel-types.ts';
import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely';
/** Identifier type for public.annunci */
@ -85,7 +86,7 @@ export default interface AnnunciTable {
web: ColumnType<boolean | null, boolean | null, boolean | null>;
caratteristiche: ColumnType<unknown | null, unknown | null, unknown | null>;
caratteristiche: ColumnType<Caratteristiche | null, Caratteristiche | null, Caratteristiche | null>;
homepage: ColumnType<boolean | null, boolean | null, boolean | null>;

View file

@ -17,8 +17,14 @@ import {
getChatEtichette,
removeChatEtichette,
} from "~/server/controllers/etichette.controller";
import { retrieveChatMessageFiles } from "~/server/controllers/storage.controller";
import { db } from "~/server/db";
import { zChatId, zEtichettaId, zUserId } from "~/server/utils/zod_types";
import {
zChatId,
zEtichettaId,
zMessageId,
zUserId,
} from "~/server/utils/zod_types";
export const chatRouter = createTRPCRouter({
addEtichettaToChat: protectedProcedure
@ -90,4 +96,9 @@ export const chatRouter = createTRPCRouter({
etichettaid: input.etichettaId,
});
}),
getMessageAttachments: protectedProcedure
.input(z.object({ messageId: zMessageId }))
.query(async ({ input }) => {
return await retrieveChatMessageFiles({ messageId: input.messageId });
}),
});

View file

@ -25,6 +25,7 @@ import {
zChatId,
zMessageId,
zUserId,
zUserStorageId,
} from "~/server/utils/zod_types";
import { getKeydbClient, getSubClient } from "~/utils/keydb";
import { withUserColorStr } from "~/utils/kysely-helper";
@ -110,6 +111,7 @@ export const chatSSERouter = createTRPCRouter({
"messages.reply_to_id",
"users.nome as sender_nome",
"users.isAdmin as sender_isAdmin",
withUserColorStr(),
jsonObjectFrom(
eb
@ -128,6 +130,19 @@ export const chatSSERouter = createTRPCRouter({
)
.limit(1),
).as("reply_source"),
eb
.exists(
eb
.selectFrom("messages_attachments")
.select("messages_attachments.userStorageId")
.whereRef(
"messages_attachments.messageId",
"=",
"messages.messageid",
),
)
.$castTo<boolean>()
.as("has_attachments"),
])
.where("messages.chatid", "=", input.chatId)
.where("messages.messageid", ">=", input.targetMsgId)
@ -187,6 +202,19 @@ export const chatSSERouter = createTRPCRouter({
.whereRef("messages.reply_to_id", "=", "reply_source.messageid")
.limit(1),
).as("reply_source"),
eb
.exists(
eb
.selectFrom("messages_attachments")
.select("messages_attachments.userStorageId")
.whereRef(
"messages_attachments.messageId",
"=",
"messages.messageid",
),
)
.$castTo<boolean>()
.as("has_attachments"),
])
.where("users.nome", "is not", null)
@ -225,6 +253,13 @@ export const chatSSERouter = createTRPCRouter({
sender: zUserId,
is_forwarded: z.boolean().default(false),
reply_to_id: zMessageId.nullable().default(null),
attachments: z
.object({
userStorageId: zUserStorageId,
storageId: z.string(),
})
.array()
.default([]),
}),
)
.mutation(async ({ input }) => {
@ -242,6 +277,20 @@ export const chatSSERouter = createTRPCRouter({
.executeTakeFirstOrThrow(
() => new Error("Failed to insert message into database"),
);
let hasAttachments = false;
if (input.attachments.length > 0) {
const newAttachments = await db
.insertInto("messages_attachments")
.values(
input.attachments.map(({ userStorageId }) => ({
messageId: newMessage.messageid,
userStorageId,
})),
)
.returning("userStorageId")
.execute();
hasAttachments = newAttachments.length > 0;
}
const rs = await db
.selectFrom("messages")
.innerJoin("users", "users.id", "messages.sender")
@ -274,7 +323,7 @@ export const chatSSERouter = createTRPCRouter({
const payload: Message = {
...newMessage,
...userInfos,
has_attachments: hasAttachments,
reply_source: rs
? {
messageid: rs.messageid,
@ -545,6 +594,19 @@ export const chatSSERouter = createTRPCRouter({
.whereRef("messages.reply_to_id", "=", "reply_source.messageid")
.limit(1),
).as("reply_source"),
eb
.exists(
eb
.selectFrom("messages_attachments")
.select("messages_attachments.userStorageId")
.whereRef(
"messages_attachments.messageId",
"=",
"messages.messageid",
),
)
.$castTo<boolean>()
.as("has_attachments"),
])
.where("messages.chatid", "=", input.chatId)
.where("messages.messageid", ">", lastMessageId)

View file

@ -1,4 +1,5 @@
import { TRPCError } from "@trpc/server";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { UsersId } from "~/schemas/public/Users";
import type { NewUsersStorage } from "~/schemas/public/UsersStorage";
import { db, type Querier } from "~/server/db";
@ -83,6 +84,43 @@ export const retrieveUserFiles = async ({ userId }: { userId: UsersId }) => {
}
};
export const retrieveChatMessageFiles = async ({
messageId,
}: {
messageId: MessagesMessageid;
}) => {
try {
const messageFiles = await db
.selectFrom("messages_attachments")
.innerJoin(
"users_storage",
"users_storage.user_storage_id",
"messages_attachments.userStorageId",
)
.select("users_storage.storageId")
.orderBy("messages_attachments.messageId", "asc")
.where("messages_attachments.messageId", "=", messageId)
.execute();
const { available, unavailable } = await retriveFilesSafely(
messageFiles.map((f) => f.storageId),
);
//remove entries in users_storage for files that no longer exist in storage
await db
.deleteFrom("users_storage")
.where("storageId", "in", unavailable)
.execute();
return available;
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error while getting chat message files: ${(e as Error).message}`,
});
}
};
export const getAllWithFromAdmin = async () => {
try {
const files = await db

View file

@ -13,6 +13,7 @@ import type { RinnoviId } from "~/schemas/public/Rinnovi";
import type { ServizioServizioId } from "~/schemas/public/Servizio";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import type { UsersId } from "~/schemas/public/Users";
import type { UsersStorageUserStorageId } from "~/schemas/public/UsersStorage";
export const zUserId = z.custom<UsersId>(
(val) => typeof val === "string",
@ -64,6 +65,11 @@ export const zTestiEStringheStingaId = z.custom<TestiEStringheStingaId>(
// "falied to validate EventDataTypes",
// );
export const zUserStorageId = z.custom<UsersStorageUserStorageId>(
(val) => typeof val === "string",
"falied to validate UserStorageId",
);
export const zBanlistId = z.custom<BanlistId>(
(val) => typeof val === "number",
"falied to validate BanlistId",
@ -110,6 +116,7 @@ export const MessageDataSchema = z.object({
color_str: z.string(),
is_forwarded: z.boolean(),
reply_to_id: zMessageId.nullable(),
has_attachments: z.boolean(),
reply_source: z
.object({
messageid: zMessageId,