feat(folders): 完善公开文件夹功能 - 添加 /explore 和 /favorites 页面

- 新增 /explore 页面:浏览和搜索公开文件夹
- 新增 /explore/[id] 页面:以只读模式查看公开文件夹
- 新增 /favorites 页面:管理收藏的文件夹
- 重构 /folders 页面:仅显示当前用户的文件夹
- 更新导航栏:添加 Explore 和 Favorites 链接
- 添加 i18n 翻译:explore 和 favorites 相关文本
- 更新 AGENTS.md:添加数据库迁移规范(必须使用 migrate dev)
This commit is contained in:
2026-03-08 14:47:35 +08:00
parent b0fa1a4201
commit d7149366e9
16 changed files with 693 additions and 293 deletions

View File

@@ -132,6 +132,20 @@ pnpm lint # ESLint
pnpm prisma studio # 数据库 GUI 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) - Tailwind CSS v4 (无 tailwind.config.ts)

View File

@@ -159,7 +159,9 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Sign In", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders" "folders": "Folders",
"explore": "Explore",
"favorites": "Favorites"
}, },
"profile": { "profile": {
"myProfile": "My Profile", "myProfile": "My Profile",
@@ -255,6 +257,26 @@
"savedToFolder": "Saved to folder: {folderName}", "savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later" "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": { "user_profile": {
"anonymous": "Anonymous", "anonymous": "Anonymous",
"email": "Email", "email": "Email",

View File

@@ -159,7 +159,9 @@
"sourceCode": "源码", "sourceCode": "源码",
"sign_in": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹" "folders": "文件夹",
"explore": "探索",
"favorites": "收藏"
}, },
"profile": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
@@ -255,6 +257,34 @@
"savedToFolder": "已保存到文件夹:{folderName}", "savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试" "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": { "user_profile": {
"anonymous": "匿名", "anonymous": "匿名",
"email": "邮箱", "email": "邮箱",

View File

@@ -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;

View 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>
);
}

View 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} />;
}

View 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} />;
}

View 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>
);
}

View 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} />;
}

View File

@@ -6,9 +6,7 @@ import {
FolderPen, FolderPen,
FolderPlus, FolderPlus,
Globe, Globe,
Heart,
Lock, Lock,
Search,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button"; import { CircleButton, LightButton } from "@/design-system/base/button";
@@ -24,22 +22,16 @@ import {
actionDeleteFolderById, actionDeleteFolderById,
actionGetFoldersWithTotalPairsByUserId, actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById, actionRenameFolderById,
actionSearchPublicFolders,
actionSetFolderVisibility, actionSetFolderVisibility,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-aciton"; } from "@/modules/folder/folder-aciton";
import { TPublicFolder, TSharedFolderWithTotalPairs } from "@/shared/folder-type"; import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
type TabType = "my" | "public"; interface FolderCardProps {
interface FolderProps {
folder: TSharedFolderWithTotalPairs; folder: TSharedFolderWithTotalPairs;
refresh: () => void; refresh: () => void;
showVisibility?: boolean;
} }
const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => { const FolderCard = ({ folder, refresh }: FolderCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
@@ -69,16 +61,14 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3> <h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
{showVisibility && ( <span className="flex items-center gap-1 text-xs text-gray-400">
<span className="flex items-center gap-1 text-xs text-gray-400"> {folder.visibility === "PUBLIC" ? (
{folder.visibility === "PUBLIC" ? ( <Globe size={12} />
<Globe size={12} /> ) : (
) : ( <Lock size={12} />
<Lock size={12} /> )}
)} {folder.visibility === "PUBLIC" ? t("public") : t("private")}
{folder.visibility === "PUBLIC" ? t("public") : t("private")} </span>
</span>
)}
</div> </div>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", { {t("folderInfo", {
@@ -91,32 +81,28 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
</div> </div>
<div className="flex items-center gap-1 ml-4"> <div className="flex items-center gap-1 ml-4">
{showVisibility && ( <CircleButton
<CircleButton onClick={handleToggleVisibility}
onClick={handleToggleVisibility} title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")} >
> {folder.visibility === "PUBLIC" ? (
{folder.visibility === "PUBLIC" ? ( <Lock size={18} />
<Lock size={18} /> ) : (
) : ( <Globe size={18} />
<Globe size={18} /> )}
)} </CircleButton>
</CircleButton>
)}
<CircleButton <CircleButton
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim(); const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) { if (newName && newName.length > 0) {
actionRenameFolderById(folder.id, newName) actionRenameFolderById(folder.id, newName).then((result) => {
.then(result => { if (result.success) {
if (result.success) { refresh();
refresh(); } else {
} toast.error(result.message);
else { }
toast.error(result.message); });
}
});
} }
}} }}
> >
@@ -127,15 +113,13 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
e.stopPropagation(); e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name })); const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) { if (confirm === folder.name) {
actionDeleteFolderById(folder.id) actionDeleteFolderById(folder.id).then((result) => {
.then(result => { if (result.success) {
if (result.success) { refresh();
refresh(); } else {
} toast.error(result.message);
else { }
toast.error(result.message); });
}
});
} }
}} }}
className="text-gray-400 hover:text-red-500 hover:bg-red-50" className="text-gray-400 hover:text-red-500 hover:bg-red-50"
@@ -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 { interface FoldersClientProps {
userId: string | null; userId: string;
initialPublicFolders: TPublicFolder[];
} }
export function FoldersClient({ userId, initialPublicFolders }: FoldersClientProps) { export function FoldersClient({ userId }: FoldersClientProps) {
const t = useTranslations("folders"); const t = useTranslations("folders");
const router = useRouter();
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]); const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders); const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>(userId ? "my" : "public");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
if (userId) { loadFolders();
setLoading(true);
actionGetFoldersWithTotalPairsByUserId(userId)
.then((result) => {
if (result.success && result.data) {
setFolders(result.data);
}
})
.finally(() => {
setLoading(false);
});
}
}, [userId]); }, [userId]);
const updateFolders = async () => { const loadFolders = async () => {
if (!userId) return;
setLoading(true); setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId); const result = await actionGetFoldersWithTotalPairsByUserId(userId);
if (result.success && result.data) { if (result.success && result.data) {
@@ -271,28 +155,14 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
setLoading(false); 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 () => { const handleCreateFolder = async () => {
if (!userId) return;
const folderName = prompt(t("enterFolderName")); const folderName = prompt(t("enterFolderName"));
if (!folderName) return; if (!folderName) return;
setLoading(true); setLoading(true);
try { try {
const result = await actionCreateFolder(userId, folderName); const result = await actionCreateFolder(userId, folderName);
if (result.success) { if (result.success) {
updateFolders(); loadFolders();
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -305,109 +175,36 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-4"> <LightButton
{userId && ( onClick={handleCreateFolder}
<button disabled={loading}
onClick={() => setActiveTab("my")} className="w-full border-dashed mb-4"
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${ >
activeTab === "my" <FolderPlus size={20} />
? "bg-primary-500 text-white" <span>{loading ? t("creating") : t("newFolder")}</span>
: "bg-gray-100 text-gray-600 hover:bg-gray-200" </LightButton>
}`}
>
{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" && ( <CardList>
<div className="flex items-center gap-2 mb-4"> {loading ? (
<div className="relative flex-1"> <div className="p-8 text-center">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" /> <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<input <p className="text-sm text-gray-500">{t("loading")}</p>
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> </div>
<CircleButton onClick={handleSearch}> ) : folders.length === 0 ? (
<Search size={18} /> <div className="text-center py-12 text-gray-400">
</CircleButton> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
</div> <FolderPlus size={24} className="text-gray-400" />
)}
{activeTab === "my" && userId && (
<LightButton
onClick={handleCreateFolder}
disabled={loading}
className="w-full border-dashed"
>
<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> </div>
) : activeTab === "my" && userId ? ( <p className="text-sm">{t("noFoldersYet")}</p>
folders.length === 0 ? ( </div>
<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"> folders
<FolderPlus size={24} className="text-gray-400" /> .toSorted((a, b) => b.id - a.id)
</div> .map((folder) => (
<p className="text-sm">{t("noFoldersYet")}</p> <FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
</div> ))
) : ( )}
folders </CardList>
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
refresh={updateFolders}
showVisibility={true}
/>
))
)
) : (
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> </PageLayout>
); );
} }

View File

@@ -1,29 +1,16 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { FoldersClient } from "./FoldersClient"; import { FoldersClient } from "./FoldersClient";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton"; import { redirect } from "next/navigation";
export default async function FoldersPage() { export default async function FoldersPage() {
const session = await auth.api.getSession( const session = await auth.api.getSession(
{ headers: await headers() } { headers: await headers() }
); );
const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
if (!session) { if (!session) {
return ( redirect("/login?redirect=/folders");
<FoldersClient
userId={null}
initialPublicFolders={publicFolders}
/>
);
} }
return ( return <FoldersClient userId={session.user.id} />;
<FoldersClient
userId={session.user.id}
initialPublicFolders={publicFolders}
/>
);
} }

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import { IMAGES } from "@/config/images"; 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 { LanguageSettings } from "./LanguageSettings";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
@@ -41,6 +41,22 @@ export async function Navbar() {
<GhostLightButton href="/folders" className="md:hidden! block!" size="md"> <GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} /> <Folder size={20} />
</GhostLightButton> </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 <GhostLightButton
className="hidden! md:block!" className="hidden! md:block!"
size="md" size="md"

View File

@@ -3,8 +3,39 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto"; import {
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } from "./folder-repository"; 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 { validate } from "@/utils/validate";
import z from "zod"; import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant"; 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.',
};
}
}

View File

@@ -84,3 +84,21 @@ export type ActionOutputCheckFavorite = {
favoriteCount: number; 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[];
};

View File

@@ -71,3 +71,21 @@ export type RepoOutputFavoriteStatus = {
isFavorited: boolean; isFavorited: boolean;
favoriteCount: number; 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;
};

View File

@@ -11,6 +11,8 @@ import {
RepoInputToggleFavorite, RepoInputToggleFavorite,
RepoInputCheckFavorite, RepoInputCheckFavorite,
RepoOutputFavoriteStatus, RepoOutputFavoriteStatus,
RepoInputGetUserFavorites,
RepoOutputUserFavorite,
} from "./folder-repository-dto"; } from "./folder-repository-dto";
import { Visibility } from "../../../generated/prisma/enums"; import { Visibility } from "../../../generated/prisma/enums";
@@ -272,3 +274,34 @@ export async function repoCheckFavorite(
favoriteCount: count, 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,
}));
}