category select; publish article form part

This commit is contained in:
2025-03-08 20:12:22 +01:00
parent 96b989799a
commit 47a79cd171
14 changed files with 1154 additions and 111 deletions
+1 -1
View File
@@ -3,5 +3,5 @@ export type AppConfig = {
};
export const appConfig: AppConfig = {
name: "Wiki Antifa",
name: "Logipedia",
};
+125 -37
View File
@@ -1,6 +1,6 @@
"use client";
import { Article } from "@/server/db/schema";
import { Article, Category } from "@/server/db/schema";
import React from "react";
@@ -21,21 +21,35 @@ import { cn, debounce } from "@/lib/utils";
import { updateArticle } from "@/server/actions/article";
import Editor from "./editor";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import CategorySelect from "@/components/category/category-select";
import {
Check,
CheckCircle,
MessageCircleWarning,
XCircle,
} from "lucide-react";
import { Switch } from "@/components/ui/switch";
import PublishArticleAlertDialog from "../publish-article-alert-dialog";
import { Label } from "@/components/ui/label";
export default ({ server_article }: { server_article: Article }) => {
const defaultValues = {
title: server_article?.title ?? "",
content:
server_article?.content ??
`<h2>
Hey bearbeite mich!
</h2>`,
};
const [selectedCategory, setSelectedCategory] =
React.useState<Category | null>(null);
const [loading, setLoading] = React.useState(false);
const form = useForm<z.infer<typeof articleSchema>>({
resolver: zodResolver(articleSchema),
defaultValues,
defaultValues: {
title: server_article?.title ?? "",
published: server_article?.published ?? false,
categoryId: server_article?.categoryId ?? "",
authorId: server_article?.authorId ?? "",
content:
server_article?.content ??
`<h2>
Hey bearbeite mich!
</h2>`,
},
});
// 2. Define a submit handler.
@@ -47,33 +61,37 @@ export default ({ server_article }: { server_article: Article }) => {
}
const debouncedSubmit = React.useCallback(
debounce(() => {
if (!form.formState.isDirty) return;
form.handleSubmit(onSubmit)();
}, 3000),
[form],
);
const published = form.watch("published");
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<TextareaAutosize
className="w-full resize-none text-4xl font-bold focus-visible:outline-none"
value={field.value}
onChange={(e) => {
field.onChange(e);
debouncedSubmit();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormControl>
<TextareaAutosize
cols={1}
className="w-full resize-none text-4xl font-bold focus-visible:outline-none"
value={field.value}
onChange={(e) => {
field.onChange(e);
debouncedSubmit();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full gap-4">
<FormField
control={form.control}
@@ -99,15 +117,85 @@ export default ({ server_article }: { server_article: Article }) => {
/>
<div
className={cn(
"sticky top-4 h-max w-full max-w-md rounded-md border-t-2 bg-muted p-4",
"sticky top-4 h-max w-full max-w-md space-y-4 rounded-md border-t-2 bg-muted p-4",
loading && "border-t-blue-600",
)}
>
<Badge>
{!form.formState.isDirty && !loading
? "gespeichert"
: "nicht gespeichert"}
</Badge>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={"outline"} className="flex items-center gap-1">
{!form.formState.isDirty && !loading ? (
<>
<CheckCircle className="size-4 text-emerald-600" />
<span>Gespeichert</span>
</>
) : (
<>
<XCircle className="size-4 text-destructive" />
<span>Nicht Gespeichert</span>
</>
)}
</Badge>
<span
className={cn(
"text-xs",
published ? "text-emerald-700" : "text-muted-foreground",
)}
>
{published
? "Veröffentlicht"
: "Draft (nicht veröffentlicht)"}
</span>
</div>
<Link
href={"/editoren-hilfe"}
target="_blank"
className="size-max scale-90 p-0 text-xs text-muted-foreground"
>
<span>? Hilfe</span>
</Link>
</div>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="rounded-md border bg-background px-4 py-2">
<FormControl>
<div className="flex items-center gap-2">
<Label>Veröffentlicht </Label>
<PublishArticleAlertDialog
published={field.value}
setPublished={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CategorySelect
initialValue={field.value}
onSelect={(categoryId) => {
field.onChange(categoryId);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
@@ -0,0 +1,60 @@
import React from "react";
import { Switch } from "../ui/switch";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
function PublishArticleAlertDialog({
published,
setPublished,
}: {
published: boolean;
setPublished: (published: boolean) => void;
}) {
const [open, setOpen] = React.useState(false);
const [checked, setChecked] = React.useState(Boolean(published));
return (
<>
<Switch
checked={checked}
onCheckedChange={(value) => {
setChecked(value);
setOpen(true);
}}
/>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger className="flex items-center" />
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Bist du dir sicher?</AlertDialogTitle>
<AlertDialogDescription>
Möchtest du diesen Artikel{" "}
{published ? "unveröffentlichen" : "veröffentlichen"}?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setChecked((prev) => !prev)}>
Abbrechen
</AlertDialogCancel>
<AlertDialogAction onClick={() => setPublished(checked)}>
Ja {published ? "Unveröffentlichen" : "Veröffentlichen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
// <div>
// </div>
);
}
export default PublishArticleAlertDialog;
@@ -0,0 +1,34 @@
"use client";
import { api } from "@/trpc/react";
import React from "react";
import { Combobox } from "../combobox";
function CategorySelect({
initialValue,
onSelect,
}: {
initialValue?: string;
onSelect: (category?: string) => void;
}) {
const { data: categories } = api.category.getAll.useQuery();
return (
<div>
<Combobox
data={
categories?.map(({ name, id }) => ({ label: name!, value: id! })) ??
[]
}
initialValue={initialValue}
onSelect={onSelect}
className="w-full"
messageUi={{
select: "Kategorie auswählen...",
placeholder: "Kategorie suchen...",
empty: "Keine Kategorien gefunden",
}}
/>
</div>
);
}
export default CategorySelect;
+115
View File
@@ -0,0 +1,115 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type ComboboxProps = {
data: {
label: string;
value: string;
}[];
onSelect: (value: string) => void;
messageUi?: {
select?: string;
empty?: string;
placeholder?: string;
};
initialValue?: string;
className?: string;
};
export function Combobox({
data,
initialValue,
messageUi,
className,
onSelect,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false);
const [value, setValue] = React.useState(initialValue ?? "");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-[200px] justify-between shadow-none hover:bg-background",
className,
)}
>
{value
? data.find((item) => item.value === value)?.label
: (messageUi?.select ?? "Select...")}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="popover-content-width-same-as-its-trigger w-full p-0">
<Command
filter={(value, search) => {
if (!search.trim()) return 1; // Show all when no search input
const entry = data.find((item) => item.value === value); // Assuming dataset is an array of objects with id and name
if (!entry) return 0; // If no matching entry is found, exclude it
return entry.label.toLowerCase().includes(search.toLowerCase())
? 1
: 0;
}}
>
<CommandInput
placeholder={messageUi?.placeholder ?? "Search..."}
className="h-9"
/>
<CommandList>
<CommandEmpty>{messageUi?.empty ?? "Nothing found."}</CommandEmpty>
<CommandGroup>
{data.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
onSelect(currentValue);
setOpen(false);
}}
>
<div className="flex items-center gap-2">
<span>{item.label}</span>
<span className="text-xs text-muted-foreground">
{value === item.value && "ausgewählt"}
</span>
</div>
<Check
className={cn(
"ml-auto",
value === item.value ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+141
View File
@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+153
View File
@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+2
View File
@@ -4,4 +4,6 @@ export const articleSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
authorId: z.string().optional(),
categoryId: z.string().optional(),
published: z.boolean(),
});
+2
View File
@@ -1,6 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { relations, sql } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTableCreator,
@@ -30,6 +31,7 @@ export const articles = createTable(
authorId: varchar("author_id", { length: 255 }),
content: text("content"),
categoryId: varchar("category_id", { length: 255 }),
published: boolean("published").default(false),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
+10 -6
View File
@@ -28,8 +28,7 @@
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
@@ -37,7 +36,8 @@
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%}
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
@@ -62,8 +62,7 @@
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
;
--chart-5: 340 75% 55%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
@@ -71,7 +70,8 @@
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%}
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
@@ -82,3 +82,7 @@
@apply bg-background text-foreground;
}
}
.popover-content-width-same-as-its-trigger {
width: var(--radix-popover-trigger-width);
max-height: var(--radix-popover-content-available-height);
}