diff --git a/.gitignore b/.gitignore index cb08c6e..d06edb5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ count.txt .vinxi todos.json files +data diff --git a/package.json b/package.json index 6963f5f..f6cb91b 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@prisma/client": "^7.1.0", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", - "@tanstack/react-form": "^1.0.0", + "@tanstack/react-form": "^1.27.7", "@tanstack/react-query": "^5.66.5", "@tanstack/react-query-devtools": "^5.84.2", "@tanstack/react-router": "^1.132.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7bdd305..5523293 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^0.7.0 version: 0.7.11(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-form': - specifier: ^1.0.0 - version: 1.27.6(@tanstack/react-start@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.27.7 + version: 1.27.7(@tanstack/react-start@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.66.5 version: 5.90.12(react@19.2.3) @@ -1877,8 +1877,8 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 - '@tanstack/form-core@1.27.6': - resolution: {integrity: sha512-1C4PUpOcCpivddKxtAeqdeqncxnPKiPpTVDRknDExCba+6zCsAjxgL+p3qYA3hu+EFyUAdW71rU+uqYbEa7qqA==} + '@tanstack/form-core@1.27.7': + resolution: {integrity: sha512-nvogpyE98fhb0NDw1Bf2YaCH+L7ZIUgEpqO9TkHucDn6zg3ni521boUpv0i8HKIrmmFwDYjWZoCnrgY4HYWTkw==} '@tanstack/history@1.141.0': resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} @@ -1903,8 +1903,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form@1.27.6': - resolution: {integrity: sha512-kq/68CKbCxK6TkFnGihtQ3qdrD5GPrVjfhkcqMFH/+X9jYOZDai52864T4997lC3nSEKFbUhkkXlaIy/wCSuNQ==} + '@tanstack/react-form@1.27.7': + resolution: {integrity: sha512-xTg4qrUY0fuLaSnkATLZcK3BWlnwLp7IuAb6UTbZKngiDEvvDCNTvVvHgPlgef1O2qN4klZxInRyRY6oEkXZ2A==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6604,7 +6604,7 @@ snapshots: - supports-color - typescript - '@tanstack/form-core@1.27.6': + '@tanstack/form-core@1.27.7': dependencies: '@tanstack/devtools-event-client': 0.4.0 '@tanstack/pacer-lite': 0.1.1 @@ -6631,9 +6631,9 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form@1.27.6(@tanstack/react-start@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-form@1.27.7(@tanstack/react-start@1.143.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/form-core': 1.27.6 + '@tanstack/form-core': 1.27.7 '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 optionalDependencies: diff --git a/prisma/data.ts b/prisma/data.ts new file mode 100644 index 0000000..038b96f --- /dev/null +++ b/prisma/data.ts @@ -0,0 +1,22 @@ +export const settingsData = [ + { + key: 'site_language', + value: 'en', + description: 'The language of the site', + }, + { + key: 'site_name', + value: 'Fuware', + description: 'The name of the site', + }, + { + key: 'site_description', + value: 'Fuware is a platform for creating and sharing stories', + description: 'The description of the site', + }, + { + key: 'site_keywords', + value: 'story, line, share, stories', + description: 'The keywords of the site', + }, +]; diff --git a/prisma/migrations/20251227144141_setting/migration.sql b/prisma/migrations/20251227144141_setting/migration.sql new file mode 100644 index 0000000..206228f --- /dev/null +++ b/prisma/migrations/20251227144141_setting/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "setting" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "description" TEXT NOT NULL, + "relation" TEXT NOT NULL DEFAULT 'admin', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "setting_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "setting_key_key" ON "setting"("key"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 561bec8..eb0b813 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,7 +9,7 @@ datasource db { } model User { - id String @id + id String @id @default(uuid()) name String email String emailVerified Boolean @default(false) @@ -32,7 +32,7 @@ model User { } model Session { - id String @id + id String @id @default(uuid()) expiresAt DateTime token String createdAt DateTime @default(now()) @@ -52,7 +52,7 @@ model Session { } model Account { - id String @id + id String @id @default(uuid()) accountId String providerId String userId String @@ -72,7 +72,7 @@ model Account { } model Verification { - id String @id + id String @id @default(uuid()) identifier String value String expiresAt DateTime @@ -84,7 +84,7 @@ model Verification { } model Organization { - id String @id + id String @id @default(uuid()) name String slug String logo String? @@ -100,7 +100,7 @@ model Organization { } model Member { - id String @id + id String @id @default(uuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) userId String @@ -114,7 +114,7 @@ model Member { } model Invitation { - id String @id + id String @id @default(uuid()) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) email String @@ -129,3 +129,16 @@ model Invitation { @@index([email]) @@map("invitation") } + +model Setting { + id String @id @default(uuid()) + key String @unique + value String + description String + relation String @default("admin") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("setting") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index cd1b564..688a201 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,25 +1,42 @@ -import { PrismaPg } from '@prisma/adapter-pg' -import { PrismaClient } from '../src/generated/prisma/client.js' -import { auth } from '@/lib/auth' +import { auth } from '@/lib/auth'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/client.js'; +import { settingsData } from './data.js'; const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL!, -}) +}); -const prisma = new PrismaClient({ adapter }) +const prisma = new PrismaClient({ adapter }); async function main() { - console.log('🌱 Seeding database...') + console.log('🌱 Seeding database...'); - // add admin user - await auth.api.createUser({ - body: { + // check mail exists + const mailExists = await prisma.user.findFirst({ + where: { email: 'luu.dat.tham@gmail.com', - password: 'Th@m!S@m!040390', - name: 'Sam', - role: 'admin', }, - }) + }); + if (!mailExists) { + // add admin user + await auth.api.createUser({ + body: { + email: 'luu.dat.tham@gmail.com', + password: 'Th@m!S@m!040390', + name: 'Sam', + role: 'admin', + }, + }); + } + + console.log('---------------Created admin user-----------------'); + await prisma.setting.deleteMany(); + + await prisma.setting.createMany({ + data: settingsData, + skipDuplicates: true, + }); // // Clear existing todos // await prisma.todo.deleteMany() @@ -38,9 +55,9 @@ async function main() { main() .catch((e) => { - console.error('❌ Error seeding database:', e) - process.exit(1) + console.error('❌ Error seeding database:', e); + process.exit(1); }) .finally(async () => { - await prisma.$disconnect() - }) + await prisma.$disconnect(); + }); diff --git a/src/components/AvatarUser.tsx b/src/components/AvatarUser.tsx deleted file mode 100644 index 533265b..0000000 --- a/src/components/AvatarUser.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Session } from '@/lib/auth/session'; -import { cn } from '@/lib/utils'; -import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; - -interface AvatarUserProps { - session: Session | null | undefined; - className?: string; - textSize?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'; -} - -const AvatarUser = ({ - session, - className, - textSize = 'md', -}: AvatarUserProps) => { - const imagePath = session?.user?.image - ? `./files/${session.user.image}` - : undefined; - const shortName = session?.user?.name - ?.split(' ') - .slice(0, 2) - .map((name) => name[0]) - .join(''); - - return ( - - - - {shortName} - - - ); -}; - -export default AvatarUser; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 86ad91b..1f76740 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,10 +1,10 @@ -import { Separator } from '@base-ui/react/separator' -import { BellIcon } from '@phosphor-icons/react' -import { useTranslation } from 'react-i18next' -import { useAuth } from './auth/auth-provider' -import RouterBreadcrumb from './sidebar/RouterBreadcrumb' -import { Badge } from './ui/badge' -import { Button } from './ui/button' +import { useSession } from '@/lib/auth-client'; +import { Separator } from '@base-ui/react/separator'; +import { BellIcon } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; +import RouterBreadcrumb from './sidebar/RouterBreadcrumb'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -13,12 +13,12 @@ import { DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, -} from './ui/dropdown-menu' -import { SidebarTrigger } from './ui/sidebar' +} from './ui/dropdown-menu'; +import { SidebarTrigger } from './ui/sidebar'; export default function Header() { - const { t } = useTranslation() - const { data: session } = useAuth() + const { t } = useTranslation(); + const { data: session } = useSession(); return ( <> @@ -86,5 +86,5 @@ export default function Header() { Start - SSR Demos */} - ) + ); } diff --git a/src/components/auth/AdminShow.tsx b/src/components/auth/AdminShow.tsx new file mode 100644 index 0000000..1323331 --- /dev/null +++ b/src/components/auth/AdminShow.tsx @@ -0,0 +1,10 @@ +import { useSession } from '@/lib/auth-client'; + +const AdminShow = ({ children }: { children: React.ReactNode }) => { + const { data } = useSession(); + const isAdmin = data?.user?.role ? data?.user?.role === 'admin' : false; + + return isAdmin && children; +}; + +export default AdminShow; diff --git a/src/components/auth/AuthShow.tsx b/src/components/auth/AuthShow.tsx new file mode 100644 index 0000000..4f7d62e --- /dev/null +++ b/src/components/auth/AuthShow.tsx @@ -0,0 +1,10 @@ +import { useSession } from '@/lib/auth-client'; + +const AuthShow = ({ children }: { children: React.ReactNode }) => { + const { data } = useSession(); + const isAuth = !!data; + + return isAuth && children; +}; + +export default AuthShow; diff --git a/src/components/auth/auth-provider.tsx b/src/components/auth/auth-provider.tsx deleted file mode 100644 index 38c5432..0000000 --- a/src/components/auth/auth-provider.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useSessionQuery } from '@/hooks/use-session' -import { createContext, useContext } from 'react' - -const AuthContext = createContext | null>( - null, -) - -export function AuthProvider({ children }: { children: React.ReactNode }) { - const sessionQuery = useSessionQuery() - - return ( - {children} - ) -} - -export function useAuth() { - const ctx = useContext(AuthContext) - - if (!ctx) { - throw new Error('useAuth must be used within an AuthProvider') - } - - return ctx -} diff --git a/src/components/avatar/AvatarUser.tsx b/src/components/avatar/AvatarUser.tsx new file mode 100644 index 0000000..b66db8a --- /dev/null +++ b/src/components/avatar/AvatarUser.tsx @@ -0,0 +1,44 @@ +import { useSession } from '@/lib/auth-client'; +import { cn } from '@/lib/utils'; +import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; +import RoleRing from './RoleRing'; + +interface AvatarUserProps { + className?: string; + textSize?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'; +} + +const AvatarUser = ({ className, textSize = 'md' }: AvatarUserProps) => { + const { data: session } = useSession(); + const imagePath = session?.user?.image + ? `./data/avatar/${session?.user?.image}` + : undefined; + const shortName = session?.user?.name + ?.split(' ') + .slice(0, 2) + .map((name) => name[0]) + .join(''); + + return ( + + + + + {shortName} + + + + ); +}; + +export default AvatarUser; diff --git a/src/components/avatar/RoleBadge.tsx b/src/components/avatar/RoleBadge.tsx new file mode 100644 index 0000000..d4eded3 --- /dev/null +++ b/src/components/avatar/RoleBadge.tsx @@ -0,0 +1,50 @@ +import { VariantProps } from 'class-variance-authority'; +import { useTranslation } from 'react-i18next'; +import { Badge, badgeVariants } from '../ui/badge'; + +type BadgeVariant = VariantProps['variant']; + +type RoleProps = { + type?: string | null; // type can be any string, undefined, or null + className?: string; +}; + +const RoleBadge = ({ type, className }: RoleProps) => { + const { t } = useTranslation(); + + // List all valid badge variant keys + const validBadgeVariants: BadgeVariant[] = [ + 'default', + 'secondary', + 'destructive', + 'outline', + 'ghost', + 'link', + 'admin', + 'user', + 'member', + 'owner', + ]; + + const LABEL_VALUE = { + admin: t('roleTags.admin'), + user: t('roleTags.user'), + member: t('roleTags.member'), + owner: t('roleTags.owner'), + }; + + // Determine the actual variant to apply. + // If 'type' is a valid variant key, use it. Otherwise, fallback to 'default'. + const displayVariant: BadgeVariant = + type && validBadgeVariants.includes(type as BadgeVariant) + ? (type as BadgeVariant) + : 'default'; + + return ( + + {LABEL_VALUE[(type as keyof typeof LABEL_VALUE) || 'default']} + + ); +}; + +export default RoleBadge; diff --git a/src/components/avatar/RoleRing.tsx b/src/components/avatar/RoleRing.tsx new file mode 100644 index 0000000..c78702c --- /dev/null +++ b/src/components/avatar/RoleRing.tsx @@ -0,0 +1,28 @@ +import { cn } from '@/lib/utils'; + +const RING_TYPE = { + admin: 'after:inset-ring-cyan-500', + user: 'after:inset-ring-green-500', + member: 'after:inset-ring-blue-500', + owner: 'after:inset-ring-red-500', +}; + +type RoleRingProps = { + children: React.ReactNode; + type?: string | null; +}; + +const RoleRing: React.FC = ({ children, type }) => { + return ( +
+ {children} +
+ ); +}; + +export default RoleRing; diff --git a/src/components/form/profile-form.tsx b/src/components/form/profile-form.tsx index 8700e31..b752a9d 100644 --- a/src/components/form/profile-form.tsx +++ b/src/components/form/profile-form.tsx @@ -1,32 +1,20 @@ -import { authClient } from '@/lib/auth-client'; -import i18n from '@/lib/i18n'; -import { uploadProfileImage } from '@/server/profile-service'; +import { authClient, useSession } from '@/lib/auth-client'; +import { uploadProfileImage } from '@/service/profile.api'; +import { ProfileInput, profileUpdateSchema } from '@/service/profile.schema'; import { UserCircleIcon } from '@phosphor-icons/react'; import { useForm } from '@tanstack/react-form'; import { useQueryClient } from '@tanstack/react-query'; import { useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import z from 'zod'; -import { useAuth } from '../auth/auth-provider'; -import AvatarUser from '../AvatarUser'; +import AvatarUser from '../avatar/AvatarUser'; +import RoleBadge from '../avatar/RoleBadge'; import { Button } from '../ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; import { Input } from '../ui/input'; -const profileSchema = z.object({ - name: z.string().nonempty( - i18n.t('profile.messages.is_required', { - field: i18n.t('profile.form.name'), - }), - ), - image: z.instanceof(File).optional(), -}); - -type Profile = z.infer; - -const defaultValues: Profile = { +const defaultValues: ProfileInput = { name: '', image: undefined, }; @@ -34,7 +22,7 @@ const defaultValues: Profile = { const ProfileForm = () => { const { t } = useTranslation(); const fileInputRef = useRef(null); - const { data: session, isLoading } = useAuth(); + const { data: session, isPending } = useSession(); const queryClient = useQueryClient(); const form = useForm({ @@ -43,8 +31,8 @@ const ProfileForm = () => { name: session?.user?.name || '', }, validators: { - onSubmit: profileSchema, - onChange: profileSchema, + onSubmit: profileUpdateSchema, + onChange: profileUpdateSchema, }, onSubmit: async ({ value }) => { try { @@ -70,7 +58,9 @@ const ProfileForm = () => { if (fileInputRef.current) { fileInputRef.current.value = ''; } - queryClient.invalidateQueries({ queryKey: ['session'] }); + queryClient.refetchQueries({ + queryKey: ['auth', 'session'], + }); toast.success(t('profile.messages.update_success')); }, onError: (ctx) => { @@ -82,11 +72,11 @@ const ProfileForm = () => { }, }); - if (isLoading) return null; + if (isPending) return null; if (!session?.user?.name) return null; return ( - + @@ -104,11 +94,7 @@ const ProfileForm = () => { >
- + { @@ -173,13 +159,9 @@ const ProfileForm = () => { {t('profile.form.role')} - +
+ +
diff --git a/src/components/form/settings-form.tsx b/src/components/form/settings-form.tsx new file mode 100644 index 0000000..09f411e --- /dev/null +++ b/src/components/form/settings-form.tsx @@ -0,0 +1,196 @@ +import { settingQueries } from '@/service/queries'; +import { updateSettings } from '@/service/setting.api'; +import { settingSchema, SettingsInput } from '@/service/setting.schema'; +import { GearIcon } from '@phosphor-icons/react'; +import { useForm } from '@tanstack/react-form'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Field, FieldError, FieldGroup, FieldLabel } from '../ui/field'; +import { Input } from '../ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; +import { Textarea } from '../ui/textarea'; + +const defaultValues: SettingsInput = { + site_language: '', + site_name: '', + site_description: '', + site_keywords: '', +}; + +const SettingsForm = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const { data: settings } = useQuery(settingQueries.list()); + + const updateMutation = useMutation({ + mutationFn: updateSettings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: settingQueries.all }); + toast.success(t('settings.messages.update_success')); + }, + }); + + const form = useForm({ + defaultValues: { + ...defaultValues, + site_name: settings?.site_name?.value || '', + site_description: settings?.site_description?.value || '', + site_keywords: settings?.site_keywords?.value || '', + site_language: settings?.site_language?.value || '', + }, + validators: { + onSubmit: settingSchema, + onChange: settingSchema, + }, + onSubmit: async ({ value }) => { + updateMutation.mutate({ data: value as SettingsInput }); + }, + }); + + return ( + + + + + {t('settings.ui.title')} + + + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + {t('settings.form.name')} + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + /> + {isInvalid && ( + + )} + + ); + }} + /> + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + + {t('settings.form.description')} + +