about page; added user profileform and me page

This commit is contained in:
mr-shortman 2025-03-15 17:16:35 +01:00
parent 1ccbcddae3
commit cff9dad54c
17 changed files with 321 additions and 35 deletions

View File

@ -0,0 +1,8 @@
import { appConfig } from "@/config";
import React from "react";
function Page() {
return <div>Das ist {appConfig.name}</div>;
}
export default Page;

View File

@ -0,0 +1,50 @@
"use client";
import { User } from "next-auth";
import React from "react";
import UserForm from "./user-form";
import SectionHeader from "@/components/section-header";
import Avatar from "@/components/avatar";
import { Button } from "@/components/ui/button";
import { Edit, XIcon } from "lucide-react";
function MePage({ user }: { user: User }) {
const [editProfile, setEditProfile] = React.useState(false);
return (
<>
<SectionHeader text="Dein Profil bearbeiten">
<Button onClick={() => setEditProfile(!editProfile)}>
{editProfile ? (
<>
<XIcon />
<span>Abbrechen</span>
</>
) : (
<>
<Edit />
<span>Bearbeiten</span>
</>
)}
</Button>
</SectionHeader>
<div className="flex items-center gap-4">
<Avatar
className="size-16 rounded-xl"
src={user.image}
fb={user.name}
/>
<div>
<h4 className="text-lg font-medium">{user.name}</h4>
<h4 className="text-sm text-muted-foreground">{user.email}</h4>
</div>
</div>
{editProfile && (
<div className="w-full max-w-lg">
<UserForm cb={() => setEditProfile(false)} server_user={user} />
</div>
)}
</>
);
}
export default MePage;

View File

@ -0,0 +1,71 @@
"use client";
import React from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { z } from "zod";
import { userProfileSchema } from "@/lib/validation/zod/user";
import { User } from "next-auth";
import { updateUserProfile } from "@/server/actions/user";
import { toast } from "sonner";
function UserForm({ server_user, cb }: { server_user: User; cb?: () => void }) {
const form = useForm<z.infer<typeof userProfileSchema>>({
resolver: zodResolver(userProfileSchema),
defaultValues: {
name: server_user?.name ?? "",
},
});
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof userProfileSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
const { success } = await updateUserProfile(values);
if (success) toast.success("Dein Profil wurde aktualisiert!");
else toast.error("Etwas ist fehlgeschlagen. Bitte versuche es erneut.");
cb?.();
form.reset();
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input placeholder="Max Mustermann" {...field} />
</div>
</FormControl>
<FormDescription>
Dein Name ist für alle sichtbar.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={!form.formState.isDirty}>
Speichern
</Button>
</form>
</Form>
);
}
export default UserForm;

View File

@ -0,0 +1,13 @@
import { appRoutes } from "@/config";
import { auth } from "@/server/auth";
import { redirect } from "next/navigation";
import React from "react";
import MePage from "./_components/me-page";
async function Page() {
const session = await auth();
if (!session) return redirect(appRoutes.signin);
return <MePage user={session.user} />;
}
export default Page;

View File

@ -1,13 +1,47 @@
import { api } from "@/trpc/server";
import MainPage from "./_components/main-page";
import { Article } from "@/server/db/schema";
import { appConfig, appRoutes } from "@/config";
import CategoryGrid from "@/components/category/grid/category-grid";
import ArticleGrid from "@/components/article/grid/article-grid";
import SectionHeader from "@/components/section-header";
import ArrowLink from "@/components/arrow-link";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Icons } from "@/components/icons";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default async function Home() {
const articles = await api.article.getAll({ limit: 12 });
const categories = await api.category.getAll({ limit: 6 });
const articles = await api.article.getAll({ limit: 6 });
return (
<>
<MainPage initialData={{ articles: articles as Article[], categories }} />
<Alert>
<div className="flex gap-2">
<Icons.logo className="size-8" />
<div>
<AlertTitle className="font-bold">
Wilkommen bei Logipedia!
</AlertTitle>
<AlertDescription className="text-muted-foreground">
Lorem ipsum, dolor sit amet consectetur adipisicing elit.
</AlertDescription>
</div>
<Button asChild className="ml-auto">
<Link href={appRoutes.about}>
Erfahre mehr über {appConfig.name}
</Link>
</Button>
</div>
</Alert>
<SectionHeader text="Kategorien">
<ArrowLink href={appRoutes.allCategories}>Alle Kategorien</ArrowLink>
</SectionHeader>
<CategoryGrid categories={categories} />
<SectionHeader text="Artikel">
<ArrowLink href={appRoutes.allArticles}>Alle Artikel</ArrowLink>
</SectionHeader>
<ArticleGrid articles={articles} />
</>
);
}

View File

@ -75,7 +75,7 @@ function ArticleFilterBar({
"text-xs text-muted-foreground",
hasFilter
? "flex lg:opacity-100"
: "hidden lg:opacity-0 lg:disabled:opacity-0",
: "hidden lg:flex lg:opacity-0 lg:disabled:opacity-0",
)}
onClick={() => onFilterChange(defaultFilter)}
>

View File

@ -73,7 +73,7 @@ export default function CategoryFilterBar({
"text-xs text-muted-foreground",
hasFilter
? "flex lg:opacity-100"
: "hidden lg:opacity-0 lg:disabled:opacity-0",
: "hidden lg:flex lg:opacity-0 lg:disabled:opacity-0",
)}
onClick={() => onFilterChange(defaultFilter)}
>

View File

@ -16,7 +16,8 @@ import { User } from "next-auth";
import NavTeamSection from "./nav-team-section";
import NavBranding from "./nav-branding";
import { Icons } from "@/components/icons";
import { appConfig } from "@/config";
import { appConfig, appRoutes } from "@/config";
import { Info } from "lucide-react";
export function AppSidebar({
...props
@ -47,5 +48,10 @@ const data = {
icon: Icons.discord,
external: true,
},
{
title: `Was ist ${appConfig.name}`,
url: appRoutes.about,
icon: Info,
},
],
};

View File

@ -20,6 +20,7 @@ import {
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/trpc/react";
import { Icons } from "@/components/icons";
import { appRoutes } from "@/config";
export function NavMain() {
const [{ articles, categories }] = api.app.getSidebarMain.useSuspenseQuery();
@ -32,11 +33,11 @@ export function NavMain() {
items: [
...articles.map((article) => ({
title: article.title,
url: `/artikel/${article.slug}`,
url: appRoutes.article(article.slug),
})),
{
title: "Alle Artikel",
url: "/artikel",
url: appRoutes.allArticles,
},
],
},
@ -47,11 +48,11 @@ export function NavMain() {
items: [
...categories.map((category) => ({
title: category.name,
url: `/kategorie/${category.slug}`,
url: appRoutes.category(category.slug),
})),
{
title: "Alle Kategorien",
url: "#",
url: appRoutes.allCategories,
},
],
},

View File

@ -7,6 +7,7 @@ import {
CreditCard,
LogOut,
Sparkles,
User,
} from "lucide-react";
import {
@ -81,30 +82,19 @@ export function NavUser({ user }: { user?: any }) {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
<DropdownMenuItem asChild>
<Link href={appRoutes.profile}>
<User />
Profil
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut />
Log out
<DropdownMenuItem asChild>
<Link href={appRoutes.signout}>
<LogOut />
Abmelden
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
import React from "react";
function SectionHeader({
text,
children,
className,
}: {
text: string;
children?: React.ReactNode;
className?: string;
}) {
return (
<div className="flex w-full items-center justify-between">
<h2 className={cn("text-2xl font-bold", className)}>{text}</h2>
{children}
</div>
);
}
export default SectionHeader;

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -2,6 +2,8 @@ export type RouteWithParam = (param: string) => string;
export type Route = string | RouteWithParam;
export type AppRoutes = {
about: string;
// Home and admin
home: string;
admin: {
@ -21,9 +23,11 @@ export type AppRoutes = {
// Auth routes
signin: string;
signout: string;
profile: string;
};
export const appRoutes: AppRoutes = {
about: "/logipedia",
home: "/",
admin: { base: "/admin" },
@ -40,4 +44,5 @@ export const appRoutes: AppRoutes = {
// auth
signin: "/api/auth/signin",
signout: "/api/auth/signout",
profile: "/me",
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const userProfileSchema = z.object({
name: z.string().min(1),
// image: z.string().optional(),
// email: z.string().email(),
});

View File

@ -1,7 +1,18 @@
"use server";
import { userProfileSchema } from "@/lib/validation/zod/user";
import { api } from "@/trpc/server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
export async function updateUserProfile(
profile: z.infer<typeof userProfileSchema>,
) {
const [result] = await api.users.updateProfile({ profile });
if (!result?.id) return { success: false };
revalidatePath("/me");
return { success: true };
}
export async function setUserPermissions(userId: string, permission: number) {
const result = await api.users.setPermission({ userId, permission });

View File

@ -160,7 +160,7 @@ export const articleRouter = createTRPCRouter({
.optional(),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.articles.findMany({
return (await ctx.db.query.articles.findMany({
where: input?.categoryId
? eq(articles.categoryId, input.categoryId)
: undefined,
@ -170,7 +170,7 @@ export const articleRouter = createTRPCRouter({
slug: true,
createdAt: true,
},
});
})) as Array<Article>;
}),
getCount: publicProcedure

View File

@ -1,11 +1,21 @@
import { hasPermission, Role } from "@/lib/validation/permissions";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { z } from "zod";
import { permission } from "process";
import { users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { userProfileSchema } from "@/lib/validation/zod/user";
export const usersRouter = createTRPCRouter({
updateProfile: protectedProcedure
.input(z.object({ profile: userProfileSchema }))
.mutation(async ({ ctx, input }) => {
return await ctx.db
.update(users)
.set(input.profile)
.where(eq(users.id, ctx.session.user.id))
.returning();
}),
getAll: protectedProcedure.query(async ({ ctx }) => {
const isAdmin = hasPermission(ctx.session.user.role, Role.ADMIN);
if (!isAdmin) throw new Error("You are not allowed to get all users");