feat(folders): 完善公开文件夹功能 - 添加 /explore 和 /favorites 页面
- 新增 /explore 页面:浏览和搜索公开文件夹 - 新增 /explore/[id] 页面:以只读模式查看公开文件夹 - 新增 /favorites 页面:管理收藏的文件夹 - 重构 /folders 页面:仅显示当前用户的文件夹 - 更新导航栏:添加 Explore 和 Favorites 链接 - 添加 i18n 翻译:explore 和 favorites 相关文本 - 更新 AGENTS.md:添加数据库迁移规范(必须使用 migrate dev)
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -132,6 +132,20 @@ pnpm lint # ESLint
|
||||
pnpm prisma studio # 数据库 GUI
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
**必须使用 `prisma migrate dev`,禁止使用 `db push`:**
|
||||
|
||||
```bash
|
||||
# 修改 schema 后创建迁移
|
||||
DATABASE_URL=your_db_url pnpm prisma migrate dev --name your_migration_name
|
||||
|
||||
# 生成 Prisma Client
|
||||
DATABASE_URL=your_db_url pnpm prisma generate
|
||||
```
|
||||
|
||||
`db push` 会绕过迁移历史,导致生产环境无法正确迁移。
|
||||
|
||||
## 备注
|
||||
|
||||
- Tailwind CSS v4 (无 tailwind.config.ts)
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
"folders": "Folders",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
@@ -255,6 +257,26 @@
|
||||
"savedToFolder": "Saved to folder: {folderName}",
|
||||
"saveFailed": "Save failed, please try again later"
|
||||
},
|
||||
"explore": {
|
||||
"title": "Explore",
|
||||
"subtitle": "Discover public folders",
|
||||
"searchPlaceholder": "Search public folders...",
|
||||
"loading": "Loading...",
|
||||
"noFolders": "No public folders found",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User",
|
||||
"favorite": "Favorite",
|
||||
"unfavorite": "Unfavorite",
|
||||
"pleaseLogin": "Please login first"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "My Favorites",
|
||||
"subtitle": "Folders you've favorited",
|
||||
"loading": "Loading...",
|
||||
"noFavorites": "No favorites yet",
|
||||
"folderInfo": "{userName} • {totalPairs} pairs",
|
||||
"unknownUser": "Unknown User"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "Anonymous",
|
||||
"email": "Email",
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"sourceCode": "源码",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹"
|
||||
"folders": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
@@ -255,6 +257,34 @@
|
||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||
"saveFailed": "保存失败,请稍后重试"
|
||||
},
|
||||
"explore": {
|
||||
"title": "探索",
|
||||
"subtitle": "发现公开文件夹",
|
||||
"searchPlaceholder": "搜索公开文件夹...",
|
||||
"loading": "加载中...",
|
||||
"noFolders": "没有找到公开文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户",
|
||||
"favorite": "收藏",
|
||||
"unfavorite": "取消收藏",
|
||||
"pleaseLogin": "请先登录"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏",
|
||||
"subtitle": "我收藏的文件夹",
|
||||
"loading": "加载中...",
|
||||
"noFavorites": "还没有收藏",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "我的收藏",
|
||||
"subtitle": "收藏的公开文件夹",
|
||||
"loading": "加载中...",
|
||||
"noFavorites": "还没有收藏任何文件夹",
|
||||
"folderInfo": "{userName} • {totalPairs} 个文本对",
|
||||
"unknownUser": "未知用户"
|
||||
},
|
||||
"user_profile": {
|
||||
"anonymous": "匿名",
|
||||
"email": "邮箱",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "folders" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "folder_favorites" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"folder_id" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
195
src/app/(features)/explore/ExploreClient.tsx
Normal file
195
src/app/(features)/explore/ExploreClient.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { CircleButton } from "@/design-system/base/button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import {
|
||||
actionSearchPublicFolders,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-aciton";
|
||||
import { TPublicFolder } from "@/shared/folder-type";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onFavoriteChange?: () => void;
|
||||
}
|
||||
|
||||
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("explore");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onFavoriteChange?.();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Heart
|
||||
size={14}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
<span>{favoriteCount}</span>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExploreClientProps {
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
}
|
||||
|
||||
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
|
||||
const t = useTranslations("explore");
|
||||
const router = useRouter();
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicFolders(initialPublicFolders);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const refreshFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim() || "");
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : publicFolders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
publicFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={currentUserId}
|
||||
onFavoriteChange={refreshFolders}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
29
src/app/(features)/explore/[id]/page.tsx
Normal file
29
src/app/(features)/explore/[id]/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { InFolder } from "@/app/folders/[folder_id]/InFolder";
|
||||
import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton";
|
||||
|
||||
export default async function ExploreFolderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const folderInfo = (await actionGetFolderVisibility(Number(id))).data;
|
||||
|
||||
if (!folderInfo) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
const isPublic = folderInfo.visibility === "PUBLIC";
|
||||
|
||||
if (!isPublic) {
|
||||
redirect("/explore");
|
||||
}
|
||||
|
||||
return <InFolder folderId={Number(id)} isReadOnly={true} />;
|
||||
}
|
||||
9
src/app/(features)/explore/page.tsx
Normal file
9
src/app/(features)/explore/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ExploreClient } from "./ExploreClient";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
|
||||
|
||||
export default async function ExplorePage() {
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
return <ExploreClient initialPublicFolders={publicFolders} />;
|
||||
}
|
||||
116
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
116
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Folder as Fd,
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { CardList } from "@/components/ui/CardList";
|
||||
import { actionGetUserFavorites } from "@/modules/folder/folder-aciton";
|
||||
|
||||
type UserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
interface FavoriteCardProps {
|
||||
favorite: UserFavorite;
|
||||
}
|
||||
|
||||
const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("favorites");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/explore/${favorite.folderId}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
|
||||
totalPairs: favorite.folderTotalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart size={18} className="fill-red-500 text-red-500" />
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FavoritesClientProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FavoritesClient({ userId }: FavoritesClientProps) {
|
||||
const t = useTranslations("favorites");
|
||||
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavorites();
|
||||
}, [userId]);
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetUserFavorites();
|
||||
if (result.success && result.data) {
|
||||
setFavorites(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : favorites.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Heart size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFavorites")}</p>
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((favorite) => (
|
||||
<FavoriteCard key={favorite.id} favorite={favorite} />
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
14
src/app/(features)/favorites/page.tsx
Normal file
14
src/app/(features)/favorites/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FavoritesClient } from "./FavoritesClient";
|
||||
|
||||
export default async function FavoritesPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
|
||||
if (!session) {
|
||||
redirect("/login?redirect=/favorites");
|
||||
}
|
||||
|
||||
return <FavoritesClient userId={session.user.id} />;
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
Heart,
|
||||
Lock,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
@@ -24,22 +22,16 @@ import {
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSearchPublicFolders,
|
||||
actionSetFolderVisibility,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-aciton";
|
||||
import { TPublicFolder, TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
type TabType = "my" | "public";
|
||||
|
||||
interface FolderProps {
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
refresh: () => void;
|
||||
showVisibility?: boolean;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => {
|
||||
const FolderCard = ({ folder, refresh }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
|
||||
@@ -69,7 +61,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
{showVisibility && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
@@ -78,7 +69,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
@@ -91,7 +81,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
{showVisibility && (
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
@@ -102,18 +91,15 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
)}
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
actionRenameFolderById(folder.id, newName)
|
||||
.then(result => {
|
||||
actionRenameFolderById(folder.id, newName).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
@@ -127,12 +113,10 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
actionDeleteFolderById(folder.id)
|
||||
.then(result => {
|
||||
actionDeleteFolderById(folder.id).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
@@ -148,121 +132,21 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
interface PublicFolderCardProps {
|
||||
folder: TPublicFolder;
|
||||
currentUserId?: string;
|
||||
onFavoriteChange?: () => void;
|
||||
}
|
||||
|
||||
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
const [isFavorited, setIsFavorited] = useState(false);
|
||||
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUserId) {
|
||||
actionCheckFavorite(folder.id).then(result => {
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [folder.id, currentUserId]);
|
||||
|
||||
const handleToggleFavorite = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!currentUserId) {
|
||||
toast.error(t("pleaseLogin"));
|
||||
return;
|
||||
}
|
||||
const result = await actionToggleFavorite(folder.id);
|
||||
if (result.success && result.data) {
|
||||
setIsFavorited(result.data.isFavorited);
|
||||
setFavoriteCount(result.data.favoriteCount);
|
||||
onFavoriteChange?.();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={() => {
|
||||
router.push(`/folders/${folder.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="shrink-0 text-primary-500">
|
||||
<Fd size={24} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("publicFolderInfo", {
|
||||
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
|
||||
totalPairs: folder.totalPairs,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Heart
|
||||
size={14}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
<span>{favoriteCount}</span>
|
||||
</div>
|
||||
<CircleButton
|
||||
onClick={handleToggleFavorite}
|
||||
title={isFavorited ? t("unfavorite") : t("favorite")}
|
||||
>
|
||||
<Heart
|
||||
size={18}
|
||||
className={isFavorited ? "fill-red-500 text-red-500" : ""}
|
||||
/>
|
||||
</CircleButton>
|
||||
<ChevronRight size={20} className="text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FoldersClientProps {
|
||||
userId: string | null;
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FoldersClient({ userId, initialPublicFolders }: FoldersClientProps) {
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
const router = useRouter();
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>(userId ? "my" : "public");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setLoading(true);
|
||||
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const updateFolders = async () => {
|
||||
if (!userId) return;
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
@@ -271,28 +155,14 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setPublicFolders(initialPublicFolders);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const result = await actionSearchPublicFolders(searchQuery.trim());
|
||||
if (result.success && result.data) {
|
||||
setPublicFolders(result.data);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!userId) return;
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await actionCreateFolder(userId, folderName);
|
||||
if (result.success) {
|
||||
updateFolders();
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -305,70 +175,22 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
||||
<PageLayout>
|
||||
<PageHeader title={t("title")} subtitle={t("subtitle")} />
|
||||
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{userId && (
|
||||
<button
|
||||
onClick={() => setActiveTab("my")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === "my"
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("myFolders")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab("public")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === "public"
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("publicFolders")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "public" && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "my" && userId && (
|
||||
<LightButton
|
||||
onClick={handleCreateFolder}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed"
|
||||
className="w-full border-dashed mb-4"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</LightButton>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
|
||||
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||
</div>
|
||||
) : activeTab === "my" && userId ? (
|
||||
folders.length === 0 ? (
|
||||
) : folders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
@@ -377,37 +199,12 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
||||
</div>
|
||||
) : (
|
||||
folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.toSorted((a, b) => b.id - a.id)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
refresh={updateFolders}
|
||||
showVisibility={true}
|
||||
/>
|
||||
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
|
||||
))
|
||||
)
|
||||
) : (
|
||||
publicFolders.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<Fd size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noPublicFolders")}</p>
|
||||
</div>
|
||||
) : (
|
||||
publicFolders.map((folder) => (
|
||||
<PublicFolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
currentUserId={userId ?? undefined}
|
||||
onFavoriteChange={handleSearch}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { auth } from "@/auth";
|
||||
import { FoldersClient } from "./FoldersClient";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<FoldersClient
|
||||
userId={null}
|
||||
initialPublicFolders={publicFolders}
|
||||
/>
|
||||
);
|
||||
redirect("/login?redirect=/folders");
|
||||
}
|
||||
|
||||
return (
|
||||
<FoldersClient
|
||||
userId={session.user.id}
|
||||
initialPublicFolders={publicFolders}
|
||||
/>
|
||||
);
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import { IMAGES } from "@/config/images";
|
||||
import { Folder, Home, User } from "lucide-react";
|
||||
import { Compass, Folder, Heart, Home, User } from "lucide-react";
|
||||
import { LanguageSettings } from "./LanguageSettings";
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
@@ -41,6 +41,22 @@ export async function Navbar() {
|
||||
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
||||
<Folder size={20} />
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
|
||||
{t("explore")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
|
||||
<Compass size={20} />
|
||||
</GhostLightButton>
|
||||
{session && (
|
||||
<>
|
||||
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
|
||||
{t("favorites")}
|
||||
</GhostLightButton>
|
||||
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
|
||||
<Heart size={20} />
|
||||
</GhostLightButton>
|
||||
</>
|
||||
)}
|
||||
<GhostLightButton
|
||||
className="hidden! md:block!"
|
||||
size="md"
|
||||
|
||||
@@ -3,8 +3,39 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
|
||||
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } from "./folder-repository";
|
||||
import {
|
||||
ActionInputCreatePair,
|
||||
ActionInputUpdatePairById,
|
||||
ActionOutputGetFoldersWithTotalPairsByUserId,
|
||||
ActionOutputGetPublicFolders,
|
||||
ActionOutputSetFolderVisibility,
|
||||
ActionOutputToggleFavorite,
|
||||
ActionOutputCheckFavorite,
|
||||
ActionOutputGetUserFavorites,
|
||||
ActionOutputUserFavorite,
|
||||
validateActionInputCreatePair,
|
||||
validateActionInputUpdatePairById,
|
||||
} from "./folder-action-dto";
|
||||
import {
|
||||
repoCreateFolder,
|
||||
repoCreatePair,
|
||||
repoDeleteFolderById,
|
||||
repoDeletePairById,
|
||||
repoGetFolderIdByPairId,
|
||||
repoGetFolderVisibility,
|
||||
repoGetFoldersByUserId,
|
||||
repoGetFoldersWithTotalPairsByUserId,
|
||||
repoGetPairsByFolderId,
|
||||
repoGetPublicFolders,
|
||||
repoGetUserIdByFolderId,
|
||||
repoRenameFolderById,
|
||||
repoSearchPublicFolders,
|
||||
repoUpdateFolderVisibility,
|
||||
repoUpdatePairById,
|
||||
repoToggleFavorite,
|
||||
repoCheckFavorite,
|
||||
repoGetUserFavorites,
|
||||
} from "./folder-repository";
|
||||
import { validate } from "@/utils/validate";
|
||||
import z from "zod";
|
||||
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
|
||||
@@ -425,3 +456,41 @@ export async function actionCheckFavorite(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
|
||||
try {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session?.user?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
};
|
||||
}
|
||||
|
||||
const favorites = await repoGetUserFavorites({
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'success',
|
||||
data: favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folderName,
|
||||
folderCreatedAt: fav.folderCreatedAt,
|
||||
folderTotalPairs: fav.folderTotalPairs,
|
||||
folderOwnerId: fav.folderOwnerId,
|
||||
folderOwnerName: fav.folderOwnerName,
|
||||
folderOwnerUsername: fav.folderOwnerUsername,
|
||||
favoritedAt: fav.favoritedAt,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Unknown error occured.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,3 +84,21 @@ export type ActionOutputCheckFavorite = {
|
||||
favoriteCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ActionOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
export type ActionOutputGetUserFavorites = {
|
||||
message: string;
|
||||
success: boolean;
|
||||
data?: ActionOutputUserFavorite[];
|
||||
};
|
||||
|
||||
@@ -71,3 +71,21 @@ export type RepoOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export interface RepoInputGetUserFavorites {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export type RepoOutputUserFavorite = {
|
||||
id: number;
|
||||
folderId: number;
|
||||
folderName: string;
|
||||
folderCreatedAt: Date;
|
||||
folderTotalPairs: number;
|
||||
folderOwnerId: string;
|
||||
folderOwnerName: string | null;
|
||||
folderOwnerUsername: string | null;
|
||||
favoritedAt: Date;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RepoInputToggleFavorite,
|
||||
RepoInputCheckFavorite,
|
||||
RepoOutputFavoriteStatus,
|
||||
RepoInputGetUserFavorites,
|
||||
RepoOutputUserFavorite,
|
||||
} from "./folder-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
@@ -272,3 +274,34 @@ export async function repoCheckFavorite(
|
||||
favoriteCount: count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
|
||||
const { userId, limit = 50, offset = 0 } = input;
|
||||
|
||||
const favorites = await prisma.folderFavorite.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
folder: {
|
||||
include: {
|
||||
_count: { select: { pairs: true } },
|
||||
user: { select: { name: true, username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return favorites.map((fav) => ({
|
||||
id: fav.id,
|
||||
folderId: fav.folderId,
|
||||
folderName: fav.folder.name,
|
||||
folderCreatedAt: fav.folder.createdAt,
|
||||
folderTotalPairs: fav.folder._count.pairs,
|
||||
folderOwnerId: fav.folder.userId,
|
||||
folderOwnerName: fav.folder.user.name,
|
||||
folderOwnerUsername: fav.folder.user.username,
|
||||
favoritedAt: fav.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user