feat(page): add page
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/cms/:path*",
|
||||
destination: `${process.env.CMS_SERVICE_BASE_URL || "http://localhost:3000"}/api/v1/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
15
package.json
15
package.json
@@ -11,15 +11,28 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"react-hook-form": "^7.71.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
|
||||
122
src/app/(dashboard)/articles/page.tsx
Normal file
122
src/app/(dashboard)/articles/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/services/api";
|
||||
import { Article, PaginatedResponse } from "@/services/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function ArticlesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Article>>("/articles");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/articles/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["articles"] });
|
||||
toast({ title: "删除成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("确定要删除吗?")) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center text-muted-foreground">加载中...</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">文章管理</h1>
|
||||
<Button onClick={() => alert("编辑器开发中...")}>
|
||||
<Plus className="mr-2 h-4 w-4" /> 创建文章
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>发布时间</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.data.map((article) => (
|
||||
<TableRow key={article.id}>
|
||||
<TableCell className="font-medium">{article.title}</TableCell>
|
||||
<TableCell>{article.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
article.status === "published" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{article.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{article.published_at
|
||||
? format(new Date(article.published_at), "yyyy-MM-dd HH:mm")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(article.updated_at), "yyyy-MM-dd HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(article.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/(dashboard)/columns/page.tsx
Normal file
106
src/app/(dashboard)/columns/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/services/api";
|
||||
import { Column, PaginatedResponse } from "@/services/types";
|
||||
import { ColumnsTable } from "@/components/modules/columns-table";
|
||||
import { ColumnDialog } from "@/components/modules/column-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function ColumnsPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingColumn, setEditingColumn] = useState<Column | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["columns"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Column>>("/columns");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: any) => api.post("/columns", values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
||||
setOpen(false);
|
||||
toast({ title: "创建成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, values }: { id: string; values: any }) =>
|
||||
api.patch(`/columns/${id}`, values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
||||
setOpen(false);
|
||||
setEditingColumn(null);
|
||||
toast({ title: "更新成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/columns/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["columns"] });
|
||||
toast({ title: "删除成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
if (editingColumn) {
|
||||
updateMutation.mutate({ id: editingColumn.id, values });
|
||||
} else {
|
||||
createMutation.mutate(values);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (column: Column) => {
|
||||
setEditingColumn(column);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("确定要删除吗?")) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-muted-foreground">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">栏目管理</h1>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingColumn(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> 创建栏目
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ColumnsTable
|
||||
data={data?.data || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<ColumnDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
column={editingColumn}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/app/(dashboard)/dashboard/page.tsx
Normal file
98
src/app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/services/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PaginatedResponse, Article, Column, Tag, Media } from "@/services/types";
|
||||
import { FileText, PanelLeft, Tags, Image as ImageIcon } from "lucide-react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: articles } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Article>>("/articles");
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
const { data: columns } = useQuery({
|
||||
queryKey: ["columns"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Column>>("/columns");
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Tag>>("/tags");
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
const { data: media } = useQuery({
|
||||
queryKey: ["media"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Media>>("/media");
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = [articles, columns, tags, media].every((x) => x === undefined);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-muted-foreground">加载中...</div>;
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "文章总数",
|
||||
value: articles?.total || 0,
|
||||
icon: FileText,
|
||||
description: "已发布和草稿状态的文章",
|
||||
},
|
||||
{
|
||||
title: "栏目数量",
|
||||
value: columns?.total || 0,
|
||||
icon: PanelLeft,
|
||||
description: "内容分类栏目",
|
||||
},
|
||||
{
|
||||
title: "标签数量",
|
||||
value: tags?.total || 0,
|
||||
icon: Tags,
|
||||
description: "用于内容标记",
|
||||
},
|
||||
{
|
||||
title: "媒体资源",
|
||||
value: media?.total || 0,
|
||||
icon: ImageIcon,
|
||||
description: "图片和文件资源",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">概览</h1>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.title}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stat.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/app/(dashboard)/layout.tsx
Normal file
21
src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Sidebar, Header } from "@/components/layout/dashboard-layout";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
|
||||
<div className="hidden border-r bg-muted/40 md:block">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Header />
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/app/(dashboard)/media/page.tsx
Normal file
139
src/app/(dashboard)/media/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/services/api";
|
||||
import { Media, PaginatedResponse } from "@/services/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Upload, Trash2, Copy } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import Image from "next/image";
|
||||
import { format } from "date-fns";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
export default function MediaPage() {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["media"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Media>>("/media");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
// Assuming API endpoint is /media/upload or similar, checking API docs
|
||||
// API.md might specify. Assuming standard POST /media with multipart
|
||||
return api.post("/media", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
toast({ title: "上传成功" });
|
||||
setUploading(false);
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false);
|
||||
toast({ title: "上传失败", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/media/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
toast({ title: "删除成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setUploading(true);
|
||||
uploadMutation.mutate(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({ title: "链接已复制" });
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("确定要删除吗?")) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-muted-foreground">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">媒体库</h1>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<Button disabled={uploading}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{uploading ? "上传中..." : "上传图片"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{data?.data.map((item) => (
|
||||
<Card key={item.id} className="overflow-hidden group">
|
||||
<CardContent className="p-0 relative aspect-square">
|
||||
<Image
|
||||
src={item.url}
|
||||
alt={item.id}
|
||||
fill
|
||||
className="object-cover transition-transform group-hover:scale-105"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 25vw, 16vw"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => handleCopyUrl(item.url)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="p-2 text-xs text-muted-foreground truncate">
|
||||
{format(new Date(item.created_at), "yyyy-MM-dd")}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{data?.data.length === 0 && (
|
||||
<div className="col-span-full text-center py-10 text-muted-foreground">
|
||||
暂无图片
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/app/(dashboard)/tags/page.tsx
Normal file
106
src/app/(dashboard)/tags/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import api from "@/services/api";
|
||||
import { Tag, PaginatedResponse } from "@/services/types";
|
||||
import { TagsTable } from "@/components/modules/tags-table";
|
||||
import { TagDialog } from "@/components/modules/tag-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
|
||||
export default function TagsPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["tags"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get<PaginatedResponse<Tag>>("/tags");
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (values: any) => api.post("/tags", values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
setOpen(false);
|
||||
toast({ title: "创建成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, values }: { id: string; values: any }) =>
|
||||
api.patch(`/tags/${id}`, values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
setOpen(false);
|
||||
setEditingTag(null);
|
||||
toast({ title: "更新成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/tags/${id}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tags"] });
|
||||
toast({ title: "删除成功" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: any) => {
|
||||
if (editingTag) {
|
||||
updateMutation.mutate({ id: editingTag.id, values });
|
||||
} else {
|
||||
createMutation.mutate(values);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (tag: Tag) => {
|
||||
setEditingTag(tag);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (confirm("确定要删除吗?")) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-8 text-center text-muted-foreground">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">标签管理</h1>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingTag(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" /> 创建标签
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TagsTable
|
||||
data={data?.data || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<TagDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
tag={editingTag}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const tenantId = req.nextUrl.searchParams.get("tenantId")?.trim() ?? ""
|
||||
if (!tenantId) {
|
||||
return NextResponse.json({ message: "tenantId is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = NextResponse.redirect(new URL("/", req.url), 302)
|
||||
res.cookies.set({
|
||||
name: "tenantId",
|
||||
value: tenantId,
|
||||
httpOnly: false,
|
||||
sameSite: "strict",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -1,24 +1,41 @@
|
||||
import Link from "next/link";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default async function AuthErrorPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ message?: string }>
|
||||
searchParams: Promise<{ message?: string; error?: string }>
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const message = sp?.message ?? "认证失败"
|
||||
const message = sp?.message || sp?.error || "认证过程中发生未知错误";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-sm">
|
||||
<div className="font-medium">无法完成登录</div>
|
||||
<div className="mt-2 break-words text-muted-foreground">{message}</div>
|
||||
<div className="mt-4">
|
||||
<Link className="underline underline-offset-4" href="/">
|
||||
<main className="min-h-screen flex flex-col items-center justify-center p-6 bg-background">
|
||||
<div className="w-full max-w-md p-8 space-y-6 text-center bg-card rounded-lg border shadow-sm">
|
||||
<div className="flex justify-center">
|
||||
<div className="p-3 bg-destructive/10 rounded-full">
|
||||
<AlertCircle className="w-10 h-10 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">无法完成登录</h1>
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center h-10 px-8 py-2 text-sm font-medium transition-colors rounded-md bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
返回首页重试
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export default function ClientRequiredPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-sm">
|
||||
缺少 CMS_CLIENT_ID 环境变量,无法发起 SSO 登录跳转。
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
import type { Metadata } from "next";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CMS",
|
||||
description: "CMS Frontend",
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export default function TenantRequiredPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-sm">
|
||||
缺少 tenantId(x-tenant-id header 或 tenantId cookie),无法跳转统一登录页。
|
||||
<div className="mt-2">
|
||||
开发环境可访问:/api/dev/set-tenant?tenantId=你的租户UUID
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
108
src/components/layout/dashboard-layout.tsx
Normal file
108
src/components/layout/dashboard-layout.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Tags,
|
||||
Image as ImageIcon,
|
||||
Settings,
|
||||
PanelLeft,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
|
||||
interface SidebarProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const routes = [
|
||||
{
|
||||
label: "概览",
|
||||
icon: LayoutDashboard,
|
||||
href: "/dashboard",
|
||||
active: pathname === "/dashboard",
|
||||
},
|
||||
{
|
||||
label: "栏目管理",
|
||||
icon: PanelLeft,
|
||||
href: "/columns",
|
||||
active: pathname.startsWith("/columns"),
|
||||
},
|
||||
{
|
||||
label: "标签管理",
|
||||
icon: Tags,
|
||||
href: "/tags",
|
||||
active: pathname.startsWith("/tags"),
|
||||
},
|
||||
{
|
||||
label: "媒体库",
|
||||
icon: ImageIcon,
|
||||
href: "/media",
|
||||
active: pathname.startsWith("/media"),
|
||||
},
|
||||
{
|
||||
label: "文章管理",
|
||||
icon: FileText,
|
||||
href: "/articles",
|
||||
active: pathname.startsWith("/articles"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("pb-12 min-h-screen border-r bg-card", className)}>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="px-3 py-2">
|
||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
CMS 管理平台
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{routes.map((route) => (
|
||||
<Button
|
||||
key={route.href}
|
||||
variant={route.active ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
asChild
|
||||
>
|
||||
<Link href={route.href}>
|
||||
<route.icon className="mr-2 h-4 w-4" />
|
||||
{route.label}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
window.location.href = "/login"; // Redirect handled by middleware mostly, but good to be explicit
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 border-b bg-card px-6">
|
||||
<div className="flex-1">
|
||||
{/* Breadcrumbs or Title could go here */}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<ModeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={handleLogout} title="退出登录">
|
||||
<LogOut className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
40
src/components/mode-toggle.tsx
Normal file
40
src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
135
src/components/modules/column-dialog.tsx
Normal file
135
src/components/modules/column-dialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Column } from "@/services/types";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const columnSchema = z.object({
|
||||
name: z.string().min(1, "名称不能为空"),
|
||||
slug: z.string().min(1, "Slug不能为空"),
|
||||
description: z.string().optional(),
|
||||
sort_order: z.number().int().default(0),
|
||||
});
|
||||
|
||||
type ColumnFormValues = z.infer<typeof columnSchema>;
|
||||
|
||||
interface ColumnDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
column?: Column | null;
|
||||
onSubmit: (values: ColumnFormValues) => void;
|
||||
}
|
||||
|
||||
export function ColumnDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
column,
|
||||
onSubmit,
|
||||
}: ColumnDialogProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<ColumnFormValues>({
|
||||
resolver: zodResolver(columnSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
sort_order: 0,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({
|
||||
name: column?.name || "",
|
||||
slug: column?.slug || "",
|
||||
description: column?.description || "",
|
||||
sort_order: column?.sort_order || 0,
|
||||
});
|
||||
}
|
||||
}, [open, column, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{column ? "编辑栏目" : "创建栏目"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{column ? "修改栏目信息" : "填写新栏目的基本信息"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
名称
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input id="name" {...register("name")} />
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="slug" className="text-right">
|
||||
Slug
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input id="slug" {...register("slug")} />
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.slug.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="sort_order" className="text-right">
|
||||
排序
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
{...register("sort_order", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
描述
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
className="col-span-3"
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
76
src/components/modules/columns-table.tsx
Normal file
76
src/components/modules/columns-table.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Column } from "@/services/types";
|
||||
import { format } from "date-fns";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
|
||||
interface ColumnsTableProps {
|
||||
data: Column[];
|
||||
onEdit: (column: Column) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ColumnsTable({ data, onEdit, onDelete }: ColumnsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">排序</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((column) => (
|
||||
<TableRow key={column.id}>
|
||||
<TableCell>{column.sort_order}</TableCell>
|
||||
<TableCell className="font-medium">{column.name}</TableCell>
|
||||
<TableCell>{column.slug}</TableCell>
|
||||
<TableCell>{column.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(column.created_at), "yyyy-MM-dd HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(column)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(column.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
123
src/components/modules/tag-dialog.tsx
Normal file
123
src/components/modules/tag-dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Tag } from "@/services/types";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const tagSchema = z.object({
|
||||
name: z.string().min(1, "名称不能为空"),
|
||||
slug: z.string().min(1, "Slug不能为空"),
|
||||
kind: z.string().min(1, "类型不能为空").default("default"),
|
||||
});
|
||||
|
||||
type TagFormValues = z.infer<typeof tagSchema>;
|
||||
|
||||
interface TagDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tag?: Tag | null;
|
||||
onSubmit: (values: TagFormValues) => void;
|
||||
}
|
||||
|
||||
export function TagDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tag,
|
||||
onSubmit,
|
||||
}: TagDialogProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TagFormValues>({
|
||||
resolver: zodResolver(tagSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
kind: "default",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({
|
||||
name: tag?.name || "",
|
||||
slug: tag?.slug || "",
|
||||
kind: tag?.kind || "default",
|
||||
});
|
||||
}
|
||||
}, [open, tag, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tag ? "编辑标签" : "创建标签"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tag ? "修改标签信息" : "填写新标签的基本信息"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
名称
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input id="name" {...register("name")} />
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="slug" className="text-right">
|
||||
Slug
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input id="slug" {...register("slug")} />
|
||||
{errors.slug && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.slug.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="kind" className="text-right">
|
||||
类型
|
||||
</Label>
|
||||
<div className="col-span-3">
|
||||
<Input id="kind" {...register("kind")} />
|
||||
{errors.kind && (
|
||||
<p className="text-sm text-destructive mt-1">
|
||||
{errors.kind.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
71
src/components/modules/tags-table.tsx
Normal file
71
src/components/modules/tags-table.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tag } from "@/services/types";
|
||||
import { format } from "date-fns";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface TagsTableProps {
|
||||
data: Tag[];
|
||||
onEdit: (tag: Tag) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function TagsTable({ data, onEdit, onDelete }: TagsTableProps) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell className="font-medium">{tag.name}</TableCell>
|
||||
<TableCell>{tag.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{tag.kind}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(tag.created_at), "yyyy-MM-dd HH:mm")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(tag)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(tag.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{data.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
30
src/components/providers.tsx
Normal file
30
src/components/providers.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = React.useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
52
src/components/ui/button.tsx
Normal file
52
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-base md:text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:opacity-90",
|
||||
outline: "border border-input bg-background hover:bg-secondary",
|
||||
ghost: "hover:bg-secondary",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
src/components/ui/card.tsx
Normal file
79
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
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}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.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}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
198
src/components/ui/dropdown-menu.tsx
Normal file
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
24
src/components/ui/input.tsx
Normal file
24
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base md:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
21
src/components/ui/label.tsx
Normal file
21
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
125
src/components/ui/toast.tsx
Normal file
125
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
src/components/ui/toaster.tsx
Normal file
35
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
194
src/components/ui/use-toast.ts
Normal file
194
src/components/ui/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
28
src/proxy.ts
28
src/proxy.ts
@@ -34,11 +34,7 @@ export function proxy(req: NextRequest) {
|
||||
|
||||
const currentUrl = req.nextUrl.clone();
|
||||
const pathname = currentUrl.pathname;
|
||||
if (
|
||||
pathname === "/auth-error" ||
|
||||
pathname === "/tenant-required" ||
|
||||
pathname === "/client-required"
|
||||
) {
|
||||
if (pathname === "/auth-error") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
@@ -53,13 +49,13 @@ export function proxy(req: NextRequest) {
|
||||
if (code) {
|
||||
if (!cmsServiceBaseUrl) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/client-required";
|
||||
url.search = "";
|
||||
url.pathname = "/auth-error";
|
||||
url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`;
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
const nextUrl = currentUrl.clone();
|
||||
nextUrl.searchParams.delete("code");
|
||||
const callback = `${cmsServiceBaseUrl}/auth/callback?code=${encodeURIComponent(
|
||||
const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?code=${encodeURIComponent(
|
||||
code,
|
||||
)}&tenant_id=${encodeURIComponent(tenantId)}&next=${encodeURIComponent(nextUrl.toString())}`;
|
||||
return NextResponse.redirect(callback, 302);
|
||||
@@ -74,7 +70,7 @@ export function proxy(req: NextRequest) {
|
||||
const next = encodeURIComponent(currentUrl.toString());
|
||||
if (cmsServiceBaseUrl) {
|
||||
// 跳转到 cms-service 的 refresh 接口,由它完成刷新并重定向回来
|
||||
const refreshUrl = `${cmsServiceBaseUrl}/auth/refresh?token=${encodeURIComponent(
|
||||
const refreshUrl = `${cmsServiceBaseUrl}/api/v1/auth/refresh?token=${encodeURIComponent(
|
||||
refreshToken,
|
||||
)}&next=${next}`;
|
||||
return NextResponse.redirect(refreshUrl, 302);
|
||||
@@ -83,26 +79,26 @@ export function proxy(req: NextRequest) {
|
||||
|
||||
if (!tenantId) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/tenant-required";
|
||||
url.search = "";
|
||||
url.pathname = "/auth-error";
|
||||
url.search = `?error=${encodeURIComponent("Missing Tenant ID: Please access via a valid tenant domain or set x-tenant-id header.")}`;
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
|
||||
if (!clientId) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/client-required";
|
||||
url.search = "";
|
||||
url.pathname = "/auth-error";
|
||||
url.search = `?error=${encodeURIComponent("Configuration Error: CMS_CLIENT_ID is missing")}`;
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
|
||||
const next = encodeURIComponent(currentUrl.toString());
|
||||
if (!cmsServiceBaseUrl) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/client-required";
|
||||
url.search = "";
|
||||
url.pathname = "/auth-error";
|
||||
url.search = `?error=${encodeURIComponent("Configuration Error: CMS_SERVICE_BASE_URL is missing")}`;
|
||||
return NextResponse.redirect(url, 302);
|
||||
}
|
||||
const callback = `${cmsServiceBaseUrl}/auth/callback?tenant_id=${encodeURIComponent(
|
||||
const callback = `${cmsServiceBaseUrl}/api/v1/auth/callback?tenant_id=${encodeURIComponent(
|
||||
tenantId,
|
||||
)}&next=${next}`;
|
||||
const loginUrl = `${process.env.IAM_FRONT_BASE_URL}/login?clientId=${encodeURIComponent(
|
||||
|
||||
27
src/services/api.ts
Normal file
27
src/services/api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: "/api/cms",
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// 401 Unauthorized: Redirect to login or handle refresh
|
||||
// Since middleware handles initial auth, this might happen if token expires while on page
|
||||
// Ideally, the refresh logic should be handled by the middleware or a transparent refresh mechanism
|
||||
// For now, we can redirect to login if we can't refresh
|
||||
if (typeof window !== "undefined") {
|
||||
// window.location.href = "/login"; // Or let middleware handle it on next navigation
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
71
src/services/types.ts
Normal file
71
src/services/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface Column {
|
||||
tenant_id: string;
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
parent_id: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
tenant_id: string;
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
tenant_id: string;
|
||||
id: string;
|
||||
url: string;
|
||||
mime_type: string | null;
|
||||
size_bytes: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
tenant_id: string;
|
||||
id: string;
|
||||
column_id: string | null;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string | null;
|
||||
content: string;
|
||||
status: string;
|
||||
current_version: number;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export interface ArticleVersion {
|
||||
tenant_id: string;
|
||||
id: string;
|
||||
article_id: string;
|
||||
version: number;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
content: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
Reference in New Issue
Block a user