diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 00000000..abded7b2 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,115 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.4.3" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.4.3.tgz", + "integrity": "sha512-Ob/3tVSIeuMRJBr2O23RtrnC5djRe01Lglx+TwGEmjrH9yDBJ2tftegYLnNEjRoMuzITgq9LD8168p4pzv+U/A==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.4.3", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.97", + "@opentui/solid": ">=0.1.97" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.4.3.tgz", + "integrity": "sha512-X0CAVbwoGAjTY2iecpWkx2B+GAa2jSaQKYpJ+xILopeF/OGKZUN15mjqci+L7cEuwLHV5wk3x2TStUOVCa5p0A==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json index f5448595..0e335baa 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "react": "19.2.1", "react-day-picker": "^9.7.0", "react-dom": "19.2.1", + "react-easy-crop": "^5.5.7", "react-hook-form": "^7.50.1", "react-i18next": "^15.5.3", "react-plaid-link": "4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea5c2703..72b33f13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: react-dom: specifier: 19.2.1 version: 19.2.1(react@19.2.1) + react-easy-crop: + specifier: ^5.5.7 + version: 5.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react-hook-form: specifier: ^7.50.1 version: 7.68.0(react@19.2.1) @@ -3586,6 +3589,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -3864,6 +3870,12 @@ packages: peerDependencies: react: ^19.2.1 + react-easy-crop@5.5.7: + resolution: {integrity: sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + react-hook-form@7.68.0: resolution: {integrity: sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==} engines: {node: '>=18.0.0'} @@ -7827,6 +7839,8 @@ snapshots: normalize-path@3.0.0: {} + normalize-wheel@1.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -8110,6 +8124,13 @@ snapshots: react: 19.2.1 scheduler: 0.27.0 + react-easy-crop@5.5.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + normalize-wheel: 1.0.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + tslib: 2.8.1 + react-hook-form@7.68.0(react@19.2.1): dependencies: react: 19.2.1 diff --git a/prisma/migrations/20260411120000_add_group_image/migration.sql b/prisma/migrations/20260411120000_add_group_image/migration.sql new file mode 100644 index 00000000..6aa87dd3 --- /dev/null +++ b/prisma/migrations/20260411120000_add_group_image/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."Group" +ADD COLUMN "image" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7e98901f..d02a925c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -95,6 +95,7 @@ model Group { id Int @id @default(autoincrement()) publicId String @unique name String + image String? userId Int defaultCurrency String @default("USD") createdAt DateTime @default(now()) diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 63caf0f5..5c8ea0ef 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -18,8 +18,14 @@ }, "download_splitpro_data": "Download SplitPro data", "edit_name": { + "apply_avatar": "Apply avatar", + "avatar_label": "Profile picture", + "avatar_preview": "Avatar preview", "placeholder": "Enter name", - "title": "Edit name" + "remove_avatar": "Remove avatar", + "select_avatar": "Select picture", + "zoom": "Zoom", + "title": "Edit details" }, "follow_on_x": "Follow us on X", "import_from_splitwise": "Import from Splitwise", @@ -35,8 +41,8 @@ }, "logout": "Logout", "messages": { - "submit_error": "Failed to submit feedback", - "submit_success": "Feedback submitted" + "submit_error": "Failed to update details", + "submit_success": "Details updated" }, "notifications": { "disable_notification": "Disable notification", @@ -251,6 +257,7 @@ "group_info": { "actions": "Actions", "archive_group": "Archive group", + "edit_group": "Edit group", "archive_group_details": { "can_archive": "This group will be archived and hidden from your main groups list. You can still access it later if needed.", "cant_archive": "Cannot archive group with outstanding balances. All balances must be settled first.", @@ -292,7 +299,7 @@ "messages": { "balances_recalculated": "Balances recalculated successfully", "group_archived": "Group archived successfully", - "group_name_updated": "Updated group name" + "group_name_updated": "Updated group details" }, "no_members": { "add_members": "Add members", diff --git a/src/components/Account/UpdateDetails.tsx b/src/components/Account/UpdateDetails.tsx new file mode 100644 index 00000000..3edf8a4f --- /dev/null +++ b/src/components/Account/UpdateDetails.tsx @@ -0,0 +1,313 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Camera, Pencil, X } from 'lucide-react'; +import Cropper, { type Area } from 'react-easy-crop'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { type TFunction, useTranslation } from 'next-i18next'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { env } from '~/env'; +import { prepareImageForUpload, uploadImage, validateUploadSize } from '~/utils/imageUpload'; + +import { AppDrawer } from '../ui/drawer'; +import { EntityAvatar } from '../ui/avatar'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '../ui/form'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Slider } from '../ui/slider'; +import { Button } from '../ui/button'; + +const createImage = async (url: string) => { + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = url; + + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error('Failed to load image for cropping')); + }); + + return image; +}; + +const getCroppedImage = async (imageSrc: string, pixelCrop: Area) => { + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Cannot get canvas context'); + } + + context.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height, + ); + + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Canvas toBlob returned null')); + return; + } + + resolve(blob); + }, + 'image/jpeg', + 0.9, + ); + }); +}; + +const detailsSchema = (t: TFunction) => + z.object({ + name: z + .string({ required_error: t('errors.name_required') }) + .min(1, { message: t('errors.name_required') }), + image: z.string().nullable().optional(), + }); + +type UpdateDetailsFormValues = z.infer>; + +export const UpdateName: React.FC<{ + className?: string; + defaultName: string; + defaultImage?: string | null; + onNameSubmit: (values: { name: string; image?: string | null }) => void | Promise; +}> = ({ className, defaultName, defaultImage, onNameSubmit }) => { + const [drawerOpen, setDrawerOpen] = useState(false); + const [imageSrc, setImageSrc] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const { t } = useTranslation(); + + const detailForm = useForm({ + resolver: zodResolver(detailsSchema(t)), + defaultValues: { + name: defaultName, + image: defaultImage, + }, + }); + + React.useEffect(() => { + detailForm.reset({ + name: defaultName, + image: defaultImage, + }); + }, [defaultImage, defaultName, detailForm]); + + React.useEffect( + () => () => { + if (imageSrc?.startsWith('blob:')) { + URL.revokeObjectURL(imageSrc); + } + }, + [imageSrc], + ); + + const trigger = useMemo(() => , [className]); + + const handleOpenChange = useCallback( + (openVal: boolean) => { + if (openVal !== drawerOpen) { + if (!openVal && imageSrc?.startsWith('blob:')) { + URL.revokeObjectURL(imageSrc); + setImageSrc(null); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + } + setDrawerOpen(openVal); + } + }, + [drawerOpen, imageSrc], + ); + + const handleOnActionClick = useCallback(async () => { + const isValid = await detailForm.trigger(); + if (!isValid) { + return; + } + + await detailForm.handleSubmit(async (values) => { + let nextImage = values.image; + + if (imageSrc && croppedAreaPixels) { + try { + const croppedBlob = await getCroppedImage(imageSrc, croppedAreaPixels); + let croppedFile = new File([croppedBlob], 'avatar.jpg', { type: 'image/jpeg' }); + + try { + croppedFile = await prepareImageForUpload(croppedFile); + } catch (error) { + console.error('Compression failed:', error); + toast.error(t('errors.image_compression_failed')); + } + + if (!validateUploadSize(croppedFile)) { + toast.error(t('errors.less_than', { size: env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB })); + return; + } + + nextImage = await uploadImage(croppedFile); + toast.success( + t('expense_details.add_expense_details.upload_file.messages.upload_success'), + ); + } catch (error) { + console.error('Crop/upload error:', error); + toast.error(t('errors.uploading_error')); + return; + } + } + + await onNameSubmit({ ...values, image: nextImage }); + setDrawerOpen(false); + if (imageSrc?.startsWith('blob:')) { + URL.revokeObjectURL(imageSrc); + } + setImageSrc(null); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + })(); + }, [croppedAreaPixels, detailForm, imageSrc, onNameSubmit, t]); + + const handleFileChange = useCallback( + (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) { + return; + } + + const objectUrl = URL.createObjectURL(selectedFile); + if (imageSrc?.startsWith('blob:')) { + URL.revokeObjectURL(imageSrc); + } + + setImageSrc(objectUrl); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + event.target.value = ''; + }, + [imageSrc], + ); + + const handleClearImage = useCallback(() => { + if (imageSrc?.startsWith('blob:')) { + URL.revokeObjectURL(imageSrc); + } + + detailForm.setValue('image', null, { shouldDirty: true }); + setImageSrc(null); + setCrop({ x: 0, y: 0 }); + setZoom(1); + setCroppedAreaPixels(null); + }, [detailForm, imageSrc]); + + const field = useCallback( + ({ field }: any) => ( + + + + + + + ), + [t], + ); + + return ( + +
+ + {!imageSrc ? ( +
+ +
+ + + +
+
+ ) : ( +
+
+ setCroppedAreaPixels(pixels)} + onZoomChange={setZoom} + /> +
+
+ + setZoom(val[0] ?? 1)} + /> +
+
+ )} + + + +
+ ); +}; diff --git a/src/components/Account/UpdateName.tsx b/src/components/Account/UpdateName.tsx deleted file mode 100644 index bce304ec..00000000 --- a/src/components/Account/UpdateName.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { zodResolver } from '@hookform/resolvers/zod'; -import { Pencil } from 'lucide-react'; -import React, { useCallback, useMemo, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { type TFunction, useTranslation } from 'next-i18next'; -import { z } from 'zod'; -import { AppDrawer } from '../ui/drawer'; -import { Form, FormControl, FormField, FormItem, FormMessage } from '../ui/form'; -import { Input } from '../ui/input'; - -const detailsSchema = (t: TFunction) => - z.object({ - name: z - .string({ required_error: t('errors.name_required') }) - .min(1, { message: t('errors.name_required') }), - }); - -type UpdateDetailsFormValues = z.infer>; - -export const UpdateName: React.FC<{ - className?: string; - defaultName: string; - onNameSubmit: (values: { name: string }) => void; -}> = ({ className, defaultName, onNameSubmit }) => { - const [drawerOpen, setDrawerOpen] = useState(false); - - const { t } = useTranslation(); - - const detailForm = useForm({ - resolver: zodResolver(detailsSchema(t)), - defaultValues: { - name: defaultName, - }, - }); - - const trigger = useMemo(() => , [className]); - - const handleOpenChange = useCallback( - (openVal: boolean) => { - if (openVal !== drawerOpen) { - setDrawerOpen(openVal); - } - }, - [drawerOpen], - ); - - const handleOnActionClick = useCallback(async () => { - const isValid = await detailForm.trigger(); - if (isValid) { - await detailForm.handleSubmit(onNameSubmit)(); - setDrawerOpen(false); - } - }, [detailForm, onNameSubmit]); - - const field = useCallback( - ({ field }: any) => ( - - - - - - - ), - [t], - ); - - return ( - -
- - - - -
- ); -}; diff --git a/src/components/AddExpense/UploadFile.tsx b/src/components/AddExpense/UploadFile.tsx index d530383d..cb390e18 100644 --- a/src/components/AddExpense/UploadFile.tsx +++ b/src/components/AddExpense/UploadFile.tsx @@ -2,10 +2,10 @@ import { ImagePlus, Image as ImageUploaded } from 'lucide-react'; import React, { useState } from 'react'; import { toast } from 'sonner'; import { useTranslation } from 'next-i18next'; -import imageCompression from 'browser-image-compression'; import { env } from '~/env'; import { useAddExpenseStore } from '~/store/addStore'; +import { prepareImageForUpload, uploadImage, validateUploadSize } from '~/utils/imageUpload'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; @@ -27,25 +27,14 @@ export const UploadFile: React.FC = () => { } try { - // Compress if enabled and it's an image - if (file.type.startsWith('image/')) { - try { - const options = { - maxSizeMB: env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB, - maxWidthOrHeight: 1920, - useWebWorker: true, - fileType: 'image/jpeg', - }; - file = await imageCompression(file, options); - } catch (error) { - console.error('Compression failed:', error); - toast.error(t('errors.image_compression_failed')); - } + try { + file = await prepareImageForUpload(file); + } catch (error) { + console.error('Compression failed:', error); + toast.error(t('errors.image_compression_failed')); } - // Check size after compression - const maxSize = env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024; - if (file.size > maxSize) { + if (!validateUploadSize(file)) { toast.error(t('errors.less_than', { size: env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB })); return; } @@ -53,22 +42,10 @@ export const UploadFile: React.FC = () => { setFile(file); setFileUploading(true); - const formData = new FormData(); - formData.append('file', file); - - const response = await fetch('/api/upload', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - throw new Error(response.statusText); - } - - const data = await response.json(); + const key = await uploadImage(file); toast.success(t('expense_details.add_expense_details.upload_file.messages.upload_success')); - setFileKey(data.key); + setFileKey(key); } catch (error) { console.error('Upload error:', error); toast.error(t('errors.uploading_error')); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 2c5415a8..08d94be9 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -3,6 +3,7 @@ import { Avatar as AvatarPrimitive } from 'radix-ui'; import * as React from 'react'; import { cn } from '~/lib/utils'; +import { toImageSrc } from '~/utils/imageUpload'; const Avatar = React.forwardRef< React.ElementRef, @@ -59,7 +60,10 @@ const EntityAvatar: React.FC<{ return ( - + ) { + const _values = React.useMemo( + () => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]), + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 176a0a62..e4afbf1e 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,25 +1,26 @@ +import i18nConfig from '@/next-i18next.config.js'; import { clsx } from 'clsx'; +import { type Session } from 'next-auth'; +import { SessionProvider, useSession } from 'next-auth/react'; +import { appWithTranslation, useTranslation } from 'next-i18next'; import { type AppType } from 'next/app'; import { Poppins } from 'next/font/google'; import Head from 'next/head'; import { useRouter } from 'next/router'; -import { type Session } from 'next-auth'; -import { SessionProvider, useSession } from 'next-auth/react'; import { useEffect, useState } from 'react'; import { Toaster } from 'sonner'; -import { appWithTranslation, useTranslation } from 'next-i18next'; -import i18nConfig from '@/next-i18next.config.js'; +import { LoadingSpinner } from '~/components/ui/spinner'; import { ThemeProvider } from '~/components/ui/theme-provider'; import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; -import '~/styles/globals.css'; -import { LoadingSpinner } from '~/components/ui/spinner'; import { env } from '~/env'; -import { parseCurrencyCode } from '~/lib/currency'; import { useAddExpenseStore } from '~/store/addStore'; import { useAppStore } from '~/store/appStore'; import { type NextPageWithUser } from '~/types'; import { api } from '~/utils/api'; +import 'react-easy-crop/react-easy-crop.css'; +import '~/styles/globals.css'; + const poppins = Poppins({ weight: ['200', '300', '400', '500', '600', '700'], subsets: ['latin'] }); const toastOptions = { duration: 1500 }; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 6139c457..2799c6eb 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -22,7 +22,7 @@ import { DownloadAppDrawer } from '~/components/Account/DownloadAppDrawer'; import { LanguagePicker } from '~/components/Account/LanguagePicker'; import { SubmitFeedback } from '~/components/Account/SubmitFeedback'; import { SubscribeNotification } from '~/components/Account/SubscribeNotification'; -import { UpdateName } from '~/components/Account/UpdateName'; +import { UpdateName } from '~/components/Account/UpdateDetails'; import MainLayout from '~/components/Layout/MainLayout'; import { EntityAvatar } from '~/components/ui/avatar'; import { Button } from '~/components/ui/button'; @@ -44,7 +44,7 @@ const AccountPage: NextPageWithUser<{ bankConnectionEnabled: boolean; bankConnection: string; gitRevision: string | null; -}> = ({ user, feedBackPossible, bankConnectionEnabled, bankConnection, gitRevision }) => { +}> = ({ feedBackPossible, bankConnectionEnabled, bankConnection, gitRevision }) => { const { t } = useTranslation(); const router = useRouter(); const userQuery = api.user.me.useQuery(); @@ -69,9 +69,9 @@ const AccountPage: NextPageWithUser<{ const utils = api.useUtils(); const onNameUpdate = useCallback( - async (values: { name: string }) => { + async (values: { name: string; image?: string | null }) => { try { - await updateDetailsMutation.mutateAsync({ name: values.name }); + await updateDetailsMutation.mutateAsync({ name: values.name, image: values.image }); toast.success(t('account.messages.submit_success'), { duration: 1500 }); utils.user.me.refetch().catch(console.error); } catch (error) { @@ -100,16 +100,17 @@ const AccountPage: NextPageWithUser<{
- +
{userQuery.data?.name}
-
{user.email}
+
{userQuery.data?.email}
{!userQuery.isPending && ( )} @@ -208,8 +209,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => { return { props: { - feedbackPossible: !!env.FEEDBACK_EMAIL, - bankConnectionEnabled: !!isBankConnectionConfigured(), + feedbackPossible: Boolean(env.FEEDBACK_EMAIL), + bankConnectionEnabled: Boolean(isBankConnectionConfigured()), bankConnection: whichBankConnectionConfigured(), gitRevision, ...(await customServerSideTranslations(context.locale, ['common'])), diff --git a/src/pages/groups/[groupId].tsx b/src/pages/groups/[groupId].tsx index 14615d1b..a2cbbdc9 100644 --- a/src/pages/groups/[groupId].tsx +++ b/src/pages/groups/[groupId].tsx @@ -32,7 +32,7 @@ import { Label } from '~/components/ui/label'; import { SimpleConfirmationDialog } from '~/components/SimpleConfirmationDialog'; import { Switch } from '~/components/ui/switch'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'; -import { UpdateName } from '~/components/Account/UpdateName'; +import { UpdateName } from '~/components/Account/UpdateDetails'; import { env } from '~/env'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { db } from '~/server/db'; @@ -83,7 +83,7 @@ const BalancePage: NextPageWithUser<{ }, [groupDetailQuery.data, t]); const isAdmin = groupDetailQuery.data?.userId === user.id; - const isArchived = !!groupDetailQuery.data?.archivedAt; + const isArchived = Boolean(groupDetailQuery.data?.archivedAt); const canDeleteOrArchive = groupDetailQuery.data?.userId === user.id && !groupDetailQuery.data?.groupBalances.find((bal) => 0n !== bal.amount); @@ -191,25 +191,27 @@ const BalancePage: NextPageWithUser<{
{groupDetailQuery.data?.name ?? ''}
- {isAdmin && ( - { - try { - await updateGroupDetailsMutation.mutateAsync({ - groupId, - name: values.name, - }); - toast.success(t('ui.messages.group_name_updated'), { duration: 1500 }); - await groupDetailQuery.refetch(); - } catch (error) { - toast.error(t('errors.group_name_update_failed')); - console.error(error); - } - }} - /> - )} + { + try { + await updateGroupDetailsMutation.mutateAsync({ + groupId, + name: values.name, + image: values.image, + }); + toast.success(t('group_details.messages.group_name_updated'), { + duration: 1500, + }); + await groupDetailQuery.refetch(); + } catch (error) { + toast.error(t('errors.group_name_update_failed')); + console.error(error); + } + }} + />

{t('group_details.group_info.members')}

diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index d8e3a942..1c7b4b40 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -277,7 +277,13 @@ export const groupRouter = createTRPCRouter({ }), updateGroupDetails: groupProcedure - .input(z.object({ name: z.string().min(1), groupId: z.number() })) + .input( + z.object({ + name: z.string().min(1), + image: z.string().nullable().optional(), + groupId: z.number(), + }), + ) .mutation(async ({ input, ctx }) => { const group = await ctx.db.group.findUnique({ where: { @@ -289,16 +295,13 @@ export const groupRouter = createTRPCRouter({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Group not found' }); } - if (group.userId !== ctx.session.user.id) { - throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Only creator can update the group' }); - } - const updatedGroup = await ctx.db.group.update({ where: { id: input.groupId, }, data: { name: input.name, + image: input.image, }, }); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 6b6fe5e3..33c68515 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -139,6 +139,7 @@ export const userRouter = createTRPCRouter({ .input( z.object({ name: z.string().optional(), + image: z.string().nullable().optional(), currency: z.string().optional(), obapiProviderId: z.string().optional(), bankingId: z.string().optional(), diff --git a/src/utils/imageUpload.ts b/src/utils/imageUpload.ts new file mode 100644 index 00000000..1fc415b0 --- /dev/null +++ b/src/utils/imageUpload.ts @@ -0,0 +1,52 @@ +import imageCompression from 'browser-image-compression'; + +import { env } from '~/env'; + +const compressImage = async (file: File) => { + if (!file.type.startsWith('image/')) { + return file; + } + + return imageCompression(file, { + maxSizeMB: env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB, + maxWidthOrHeight: 1920, + useWebWorker: true, + fileType: 'image/jpeg', + }); +}; + +export const validateUploadSize = (file: File) => { + const maxSize = env.NEXT_PUBLIC_UPLOAD_MAX_FILE_SIZE_MB * 1024 * 1024; + return file.size <= maxSize; +}; + +export const prepareImageForUpload = async (file: File) => compressImage(file); + +export const uploadImage = async (file: File): Promise => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(response.statusText); + } + + const data = (await response.json()) as { key: string }; + return data.key; +}; + +export const toImageSrc = (value?: string | null) => { + if (!value) { + return undefined; + } + + if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('/')) { + return value; + } + + return `/api/files/${value}`; +};