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
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "邮箱",
|
||||||
|
|||||||
@@ -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,
|
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,7 +61,6 @@ 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} />
|
||||||
@@ -78,7 +69,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
|||||||
)}
|
)}
|
||||||
{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,7 +81,6 @@ 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")}
|
||||||
@@ -102,18 +91,15 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
|||||||
<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 {
|
||||||
else {
|
|
||||||
toast.error(result.message);
|
toast.error(result.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -127,12 +113,10 @@ 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 {
|
||||||
else {
|
|
||||||
toast.error(result.message);
|
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 {
|
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,70 +175,22 @@ 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">
|
|
||||||
{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
|
<LightButton
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full border-dashed"
|
className="w-full border-dashed mb-4"
|
||||||
>
|
>
|
||||||
<FolderPlus size={20} />
|
<FolderPlus size={20} />
|
||||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||||
</LightButton>
|
</LightButton>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 text-center">
|
<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>
|
<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>
|
<p className="text-sm text-gray-500">{t("loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === "my" && userId ? (
|
) : folders.length === 0 ? (
|
||||||
folders.length === 0 ? (
|
|
||||||
<div className="text-center py-12 text-gray-400">
|
<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">
|
<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" />
|
<FolderPlus size={24} className="text-gray-400" />
|
||||||
@@ -377,37 +199,12 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
folders
|
folders
|
||||||
.toSorted((a, b) => a.id - b.id)
|
.toSorted((a, b) => b.id - a.id)
|
||||||
.map((folder) => (
|
.map((folder) => (
|
||||||
<FolderCard
|
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
|
||||||
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>
|
</CardList>
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user