feat(folders): 添加公开文件夹和收藏功能

- 新增文件夹可见性控制(公开/私有)
- 添加公开文件夹浏览和搜索
- 实现文件夹收藏功能
- 新增 FolderFavorite 数据模型
- 更新 Prisma 至 7.4.2
- 添加相关 i18n 翻译
This commit is contained in:
2026-03-08 14:20:12 +08:00
parent b407783d61
commit b0fa1a4201
13 changed files with 1019 additions and 263 deletions

View File

@@ -24,7 +24,22 @@
"noFoldersYet": "No folders yet", "noFoldersYet": "No folders yet",
"folderInfo": "ID: {id} • {totalPairs} pairs", "folderInfo": "ID: {id} • {totalPairs} pairs",
"enterFolderName": "Enter folder name:", "enterFolderName": "Enter folder name:",
"confirmDelete": "Type \"{name}\" to delete:" "confirmDelete": "Type \"{name}\" to delete:",
"myFolders": "My Folders",
"publicFolders": "Public Folders",
"public": "Public",
"private": "Private",
"setPublic": "Set Public",
"setPrivate": "Set Private",
"publicFolderInfo": "{userName} • {totalPairs} pairs",
"searchPlaceholder": "Search public folders...",
"loading": "Loading...",
"noPublicFolders": "No public folders found",
"unknownUser": "Unknown User",
"enterNewName": "Enter new name:",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"pleaseLogin": "Please login first"
}, },
"folder_id": { "folder_id": {
"unauthorized": "You are not the owner of this folder", "unauthorized": "You are not the owner of this folder",

View File

@@ -24,7 +24,22 @@
"noFoldersYet": "还没有文件夹", "noFoldersYet": "还没有文件夹",
"folderInfo": "ID: {id} • {totalPairs} 个文本对", "folderInfo": "ID: {id} • {totalPairs} 个文本对",
"enterFolderName": "输入文件夹名称:", "enterFolderName": "输入文件夹名称:",
"confirmDelete": "输入 \"{name}\" 以删除:" "confirmDelete": "输入 \"{name}\" 以删除:",
"myFolders": "我的文件夹",
"publicFolders": "公开文件夹",
"public": "公开",
"private": "私有",
"setPublic": "设为公开",
"setPrivate": "设为私有",
"publicFolderInfo": "{userName} • {totalPairs} 个文本对",
"searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...",
"noPublicFolders": "没有找到公开文件夹",
"unknownUser": "未知用户",
"enterNewName": "输入新名称:",
"favorite": "收藏",
"unfavorite": "取消收藏",
"pleaseLogin": "请先登录"
}, },
"folder_id": { "folder_id": {
"unauthorized": "您不是此文件夹的所有者", "unauthorized": "您不是此文件夹的所有者",

View File

@@ -11,8 +11,8 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.2.0", "@prisma/client": "7.4.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.4.10", "better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -43,7 +43,7 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-next": "16.1.1", "eslint-config-next": "16.1.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"prisma": "^7.2.0", "prisma": "^7.4.2",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },

373
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ model User {
accounts Account[] accounts Account[]
dictionaryLookUps DictionaryLookUp[] dictionaryLookUps DictionaryLookUp[]
folders Folder[] folders Folder[]
folderFavorites FolderFavorite[]
sessions Session[] sessions Session[]
translationHistories TranslationHistory[] translationHistories TranslationHistory[]
@@ -91,19 +92,42 @@ model Pair {
@@map("pairs") @@map("pairs")
} }
enum Visibility {
PRIVATE
PUBLIC
}
model Folder { model Folder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
userId String @map("user_id") userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at") visibility Visibility @default(PRIVATE)
updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) updatedAt DateTime @updatedAt @map("updated_at")
pairs Pair[] user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
@@index([userId]) @@index([userId])
@@index([visibility])
@@map("folders") @@map("folders")
} }
model FolderFavorite {
id Int @id @default(autoincrement())
userId String @map("user_id")
folderId Int @map("folder_id")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
folder Folder @relation(fields: [folderId], references: [id], onDelete: Cascade)
@@unique([userId, folderId])
@@index([userId])
@@index([folderId])
@@map("folder_favorites")
}
model DictionaryLookUp { model DictionaryLookUp {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String? @map("user_id") userId String? @map("user_id")

View File

@@ -5,6 +5,10 @@ import {
Folder as Fd, Folder as Fd,
FolderPen, FolderPen,
FolderPlus, FolderPlus,
Globe,
Heart,
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";
@@ -15,18 +19,41 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout"; import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader"; import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList"; import { CardList } from "@/components/ui/CardList";
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder/folder-aciton"; import {
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type"; actionCreateFolder,
actionDeleteFolderById,
actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById,
actionSearchPublicFolders,
actionSetFolderVisibility,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-aciton";
import { TPublicFolder, TSharedFolderWithTotalPairs } from "@/shared/folder-type";
type TabType = "my" | "public";
interface FolderProps { interface FolderProps {
folder: TSharedFolderWithTotalPairs; folder: TSharedFolderWithTotalPairs;
refresh: () => void; refresh: () => void;
showVisibility?: boolean;
} }
const FolderCard = ({ folder, refresh }: FolderProps) => { const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
const handleToggleVisibility = async (e: React.MouseEvent) => {
e.stopPropagation();
const newVisibility = folder.visibility === "PUBLIC" ? "PRIVATE" : "PUBLIC";
const result = await actionSetFolderVisibility(folder.id, newVisibility);
if (result.success) {
refresh();
} else {
toast.error(result.message);
}
};
return ( return (
<div <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" 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"
@@ -40,7 +67,19 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3> <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} />
) : (
<Lock size={12} />
)}
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
</span>
)}
</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", {
id: folder.id, id: folder.id,
@@ -52,10 +91,22 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
</div> </div>
<div className="flex items-center gap-1 ml-4"> <div className="flex items-center gap-1 ml-4">
{showVisibility && (
<CircleButton
onClick={handleToggleVisibility}
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
>
{folder.visibility === "PUBLIC" ? (
<Lock size={18} />
) : (
<Globe size={18} />
)}
</CircleButton>
)}
<CircleButton <CircleButton
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const newName = prompt("Input a new name.")?.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 => {
@@ -97,91 +148,263 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
); );
}; };
export function FoldersClient({ userId }: { userId: string; }) { interface PublicFolderCardProps {
folder: TPublicFolder;
currentUserId?: string;
onFavoriteChange?: () => void;
}
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
const router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>( const [isFavorited, setIsFavorited] = useState(false);
[], const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
setLoading(true); if (currentUserId) {
actionGetFoldersWithTotalPairsByUserId(userId) actionCheckFavorite(folder.id).then(result => {
.then((folders) => { if (result.success && result.data) {
if (folders.success && folders.data) { setIsFavorited(result.data.isFavorited);
setFolders(folders.data); setFavoriteCount(result.data.favoriteCount);
setLoading(false);
} }
}); });
}
}, [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[];
}
export function FoldersClient({ userId, initialPublicFolders }: FoldersClientProps) {
const t = useTranslations("folders");
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("");
useEffect(() => {
if (userId) {
setLoading(true);
actionGetFoldersWithTotalPairsByUserId(userId)
.then((result) => {
if (result.success && result.data) {
setFolders(result.data);
}
})
.finally(() => {
setLoading(false);
});
}
}, [userId]); }, [userId]);
const updateFolders = async () => { const updateFolders = async () => {
if (!userId) return;
setLoading(true); setLoading(true);
await actionGetFoldersWithTotalPairsByUserId(userId) const result = await actionGetFoldersWithTotalPairsByUserId(userId);
.then(async result => { if (result.success && result.data) {
if (!result.success) toast.error(result.message); setFolders(result.data);
else await actionGetFoldersWithTotalPairsByUserId(userId) }
.then((folders) => {
if (folders.success && folders.data) {
setFolders(folders.data);
}
});
});
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 () => {
if (!userId) return;
const folderName = prompt(t("enterFolderName"));
if (!folderName) return;
setLoading(true);
try {
const result = await actionCreateFolder(userId, folderName);
if (result.success) {
updateFolders();
} else {
toast.error(result.message);
}
} finally {
setLoading(false);
}
};
return ( return (
<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={async () => { <button
const folderName = prompt(t("enterFolderName")); onClick={() => setActiveTab("my")}
if (!folderName) return; className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
setLoading(true); activeTab === "my"
try { ? "bg-primary-500 text-white"
await actionCreateFolder(userId, folderName) : "bg-gray-100 text-gray-600 hover:bg-gray-200"
.then(result => { }`}
if (result.success) { >
updateFolders(); {t("myFolders")}
} else { </button>
toast.error(result.message); )}
} <button
}); onClick={() => setActiveTab("public")}
} finally { className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
setLoading(false); activeTab === "public"
} ? "bg-primary-500 text-white"
}} : "bg-gray-100 text-gray-600 hover:bg-gray-200"
disabled={loading} }`}
className="w-full border-dashed" >
> {t("publicFolders")}
<FolderPlus size={20} /> </button>
<span>{loading ? t("creating") : t("newFolder")}</span> </div>
</LightButton>
{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"
>
<FolderPlus size={20} />
<span>{loading ? t("creating") : t("newFolder")}</span>
</LightButton>
)}
{/* 文件夹列表 */}
<div className="mt-4"> <div className="mt-4">
<CardList> <CardList>
{folders.length === 0 ? ( {loading ? (
// 空状态 <div className="p-8 text-center">
<div className="text-center py-12 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>
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <p className="text-sm text-gray-500">{t("loading")}</p>
<FolderPlus size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFoldersYet")}</p>
</div> </div>
) : activeTab === "my" && userId ? (
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" />
</div>
<p className="text-sm">{t("noFoldersYet")}</p>
</div>
) : (
folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
refresh={updateFolders}
showVisibility={true}
/>
))
)
) : ( ) : (
// 文件夹卡片列表 publicFolders.length === 0 ? (
folders <div className="text-center py-12 text-gray-400">
.toSorted((a, b) => a.id - b.id) <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
.map((folder) => ( <Fd size={24} className="text-gray-400" />
<FolderCard </div>
<p className="text-sm">{t("noPublicFolders")}</p>
</div>
) : (
publicFolders.map((folder) => (
<PublicFolderCard
key={folder.id} key={folder.id}
folder={folder} folder={folder}
refresh={updateFolders} currentUserId={userId ?? undefined}
onFavoriteChange={handleSearch}
/> />
)) ))
)
)} )}
</CardList> </CardList>
</div> </div>

View File

@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
import { InFolder } from "./InFolder"; import { InFolder } from "./InFolder";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetUserIdByFolderId } from "@/modules/folder/folder-aciton"; import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton";
export default async function FoldersPage({ export default async function FoldersPage({
params, params,
@@ -18,9 +18,19 @@ export default async function FoldersPage({
redirect("/folders"); redirect("/folders");
} }
// Allow non-authenticated users to view folders (read-only mode) const folderInfo = (await actionGetFolderVisibility(Number(folder_id))).data;
const folderUserId = (await actionGetUserIdByFolderId(Number(folder_id))).data;
const isOwner = session?.user?.id === folderUserId; if (!folderInfo) {
redirect("/folders");
}
const isOwner = session?.user?.id === folderInfo.userId;
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isOwner && !isPublic) {
redirect("/folders");
}
const isReadOnly = !isOwner; const isReadOnly = !isOwner;
return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />; return <InFolder folderId={Number(folder_id)} isReadOnly={isReadOnly} />;

View File

@@ -1,12 +1,29 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { FoldersClient } from "./FoldersClient"; import { FoldersClient } from "./FoldersClient";
import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
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() }
); );
if (!session) redirect(`/login?redirect=/folders`);
return <FoldersClient userId={session.user.id} />; const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
if (!session) {
return (
<FoldersClient
userId={null}
initialPublicFolders={publicFolders}
/>
);
}
return (
<FoldersClient
userId={session.user.id}
initialPublicFolders={publicFolders}
/>
);
} }

View File

@@ -3,15 +3,13 @@
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, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto"; import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository"; import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } 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";
import { Visibility } from "../../../generated/prisma/enums";
/**
* Helper function to check if the current user is the owner of a folder
*/
async function checkFolderOwnership(folderId: number): Promise<boolean> { async function checkFolderOwnership(folderId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false; if (!session?.user?.id) return false;
@@ -20,9 +18,6 @@ async function checkFolderOwnership(folderId: number): Promise<boolean> {
return folderOwnerId === session.user.id; return folderOwnerId === session.user.id;
} }
/**
* Helper function to check if the current user is the owner of a pair's folder
*/
async function checkPairOwnership(pairId: number): Promise<boolean> { async function checkPairOwnership(pairId: number): Promise<boolean> {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false; if (!session?.user?.id) return false;
@@ -52,7 +47,6 @@ export async function actionGetPairsByFolderId(folderId: number) {
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) { export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try { try {
// Check ownership
const isOwner = await checkPairOwnership(id); const isOwner = await checkPairOwnership(id);
if (!isOwner) { if (!isOwner) {
return { return {
@@ -92,9 +86,24 @@ export async function actionGetUserIdByFolderId(folderId: number) {
} }
} }
export async function actionGetFolderVisibility(folderId: number) {
try {
return {
success: true,
message: 'success',
data: await repoGetFolderVisibility(folderId)
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.'
};
}
}
export async function actionDeleteFolderById(folderId: number) { export async function actionDeleteFolderById(folderId: number) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(folderId); const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) { if (!isOwner) {
return { return {
@@ -119,7 +128,6 @@ export async function actionDeleteFolderById(folderId: number) {
export async function actionDeletePairById(id: number) { export async function actionDeletePairById(id: number) {
try { try {
// Check ownership
const isOwner = await checkPairOwnership(id); const isOwner = await checkPairOwnership(id);
if (!isOwner) { if (!isOwner) {
return { return {
@@ -176,7 +184,6 @@ export async function actionGetFoldersByUserId(userId: string) {
export async function actionCreatePair(dto: ActionInputCreatePair) { export async function actionCreatePair(dto: ActionInputCreatePair) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(dto.folderId); const isOwner = await checkFolderOwnership(dto.folderId);
if (!isOwner) { if (!isOwner) {
return { return {
@@ -238,7 +245,6 @@ export async function actionCreateFolder(userId: string, folderName: string) {
export async function actionRenameFolderById(id: number, newName: string) { export async function actionRenameFolderById(id: number, newName: string) {
try { try {
// Check ownership
const isOwner = await checkFolderOwnership(id); const isOwner = await checkFolderOwnership(id);
if (!isOwner) { if (!isOwner) {
return { return {
@@ -272,3 +278,150 @@ export async function actionRenameFolderById(id: number, newName: string) {
}; };
} }
} }
export async function actionSetFolderVisibility(
folderId: number,
visibility: "PRIVATE" | "PUBLIC",
): Promise<ActionOutputSetFolderVisibility> {
try {
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
success: false,
message: 'You do not have permission to change this folder visibility.',
};
}
await repoUpdateFolderVisibility({
folderId,
visibility: visibility as Visibility,
});
return {
success: true,
message: 'success',
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionGetPublicFolders(): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoGetPublicFolders({});
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionSearchPublicFolders(query: string): Promise<ActionOutputGetPublicFolders> {
try {
const data = await repoSearchPublicFolders({ query, limit: 50 });
return {
success: true,
message: 'success',
data: data.map((folder) => ({
...folder,
visibility: folder.visibility as "PRIVATE" | "PUBLIC",
})),
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionToggleFavorite(
folderId: number,
): Promise<ActionOutputToggleFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const isFavorited = await repoToggleFavorite({
folderId,
userId: session.user.id,
});
const { favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}
export async function actionCheckFavorite(
folderId: number,
): Promise<ActionOutputCheckFavorite> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: true,
message: 'success',
data: {
isFavorited: false,
favoriteCount: 0,
},
};
}
const { isFavorited, favoriteCount } = await repoCheckFavorite({
folderId,
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: {
isFavorited,
favoriteCount,
},
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}

View File

@@ -32,3 +32,55 @@ export type ActionOutputGetFoldersWithTotalPairsByUserId = {
success: boolean, success: boolean,
data?: TSharedFolderWithTotalPairs[]; data?: TSharedFolderWithTotalPairs[];
}; };
export const schemaActionInputSetFolderVisibility = z.object({
folderId: z.number().int().positive(),
visibility: z.enum(["PRIVATE", "PUBLIC"]),
});
export type ActionInputSetFolderVisibility = z.infer<typeof schemaActionInputSetFolderVisibility>;
export const schemaActionInputSearchPublicFolders = z.object({
query: z.string().min(1).max(100),
});
export type ActionInputSearchPublicFolders = z.infer<typeof schemaActionInputSearchPublicFolders>;
export type ActionOutputPublicFolder = {
id: number;
name: string;
visibility: "PRIVATE" | "PUBLIC";
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type ActionOutputGetPublicFolders = {
message: string;
success: boolean;
data?: ActionOutputPublicFolder[];
};
export type ActionOutputSetFolderVisibility = {
message: string;
success: boolean;
};
export type ActionOutputToggleFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};
export type ActionOutputCheckFavorite = {
message: string;
success: boolean;
data?: {
isFavorited: boolean;
favoriteCount: number;
};
};

View File

@@ -1,3 +1,5 @@
import { Visibility } from "../../../generated/prisma/enums";
export interface RepoInputCreateFolder { export interface RepoInputCreateFolder {
name: string; name: string;
userId: string; userId: string;
@@ -21,3 +23,51 @@ export interface RepoInputUpdatePair {
ipa1?: string; ipa1?: string;
ipa2?: string; ipa2?: string;
} }
export interface RepoInputUpdateFolderVisibility {
folderId: number;
visibility: Visibility;
}
export interface RepoInputSearchPublicFolders {
query: string;
limit?: number;
}
export interface RepoInputGetPublicFolders {
limit?: number;
offset?: number;
orderBy?: "createdAt" | "name";
}
export type RepoOutputPublicFolder = {
id: number;
name: string;
visibility: Visibility;
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};
export type RepoOutputFolderVisibility = {
visibility: Visibility;
userId: string;
};
export interface RepoInputToggleFavorite {
folderId: number;
userId: string;
}
export interface RepoInputCheckFavorite {
folderId: number;
userId: string;
}
export type RepoOutputFavoriteStatus = {
isFavorited: boolean;
favoriteCount: number;
};

View File

@@ -1,5 +1,18 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { RepoInputCreateFolder, RepoInputCreatePair, RepoInputUpdatePair } from "./folder-repository-dto"; import {
RepoInputCreateFolder,
RepoInputCreatePair,
RepoInputUpdatePair,
RepoInputUpdateFolderVisibility,
RepoInputSearchPublicFolders,
RepoInputGetPublicFolders,
RepoOutputPublicFolder,
RepoOutputFolderVisibility,
RepoInputToggleFavorite,
RepoInputCheckFavorite,
RepoOutputFavoriteStatus,
} from "./folder-repository-dto";
import { Visibility } from "../../../generated/prisma/enums";
export async function repoCreatePair(data: RepoInputCreatePair) { export async function repoCreatePair(data: RepoInputCreatePair) {
return (await prisma.pair.create({ return (await prisma.pair.create({
@@ -63,7 +76,8 @@ export async function repoGetFoldersByUserId(userId: string) {
return { return {
id: v.id, id: v.id,
name: v.name, name: v.name,
userId: v.userId userId: v.userId,
visibility: v.visibility,
}; };
}); });
} }
@@ -95,6 +109,7 @@ export async function repoGetFoldersWithTotalPairsByUserId(userId: string) {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
userId: folder.userId, userId: folder.userId,
visibility: folder.visibility,
total: folder._count?.pairs ?? 0, total: folder._count?.pairs ?? 0,
createdAt: folder.createdAt, createdAt: folder.createdAt,
})); }));
@@ -134,3 +149,126 @@ export async function repoGetFolderIdByPairId(pairId: number) {
}); });
return pair?.folderId; return pair?.folderId;
} }
export async function repoUpdateFolderVisibility(
input: RepoInputUpdateFolderVisibility,
): Promise<void> {
await prisma.folder.update({
where: { id: input.folderId },
data: { visibility: input.visibility },
});
}
export async function repoGetFolderVisibility(
folderId: number,
): Promise<RepoOutputFolderVisibility | null> {
const folder = await prisma.folder.findUnique({
where: { id: folderId },
select: { visibility: true, userId: true },
});
return folder;
}
export async function repoGetPublicFolders(
input: RepoInputGetPublicFolders = {},
): Promise<RepoOutputPublicFolder[]> {
const { limit = 50, offset = 0, orderBy = "createdAt" } = input;
const folders = await prisma.folder.findMany({
where: { visibility: Visibility.PUBLIC },
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { [orderBy]: "desc" },
take: limit,
skip: offset,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user.name,
userUsername: folder.user.username,
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoSearchPublicFolders(
input: RepoInputSearchPublicFolders,
): Promise<RepoOutputPublicFolder[]> {
const { query, limit = 50 } = input;
const folders = await prisma.folder.findMany({
where: {
visibility: Visibility.PUBLIC,
name: { contains: query, mode: "insensitive" },
},
include: {
_count: { select: { pairs: true, favorites: true } },
user: { select: { name: true, username: true } },
},
orderBy: { createdAt: "desc" },
take: limit,
});
return folders.map((folder) => ({
id: folder.id,
name: folder.name,
visibility: folder.visibility,
createdAt: folder.createdAt,
userId: folder.userId,
userName: folder.user.name,
userUsername: folder.user.username,
totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites,
}));
}
export async function repoToggleFavorite(
input: RepoInputToggleFavorite,
): Promise<boolean> {
const existing = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
if (existing) {
await prisma.folderFavorite.delete({
where: { id: existing.id },
});
return false;
} else {
await prisma.folderFavorite.create({
data: {
userId: input.userId,
folderId: input.folderId,
},
});
return true;
}
}
export async function repoCheckFavorite(
input: RepoInputCheckFavorite,
): Promise<RepoOutputFavoriteStatus> {
const favorite = await prisma.folderFavorite.findUnique({
where: {
userId_folderId: {
userId: input.userId,
folderId: input.folderId,
},
},
});
const count = await prisma.folderFavorite.count({
where: { folderId: input.folderId },
});
return {
isFavorited: !!favorite,
favoriteCount: count,
};
}

View File

@@ -2,12 +2,14 @@ export type TSharedFolder = {
id: number, id: number,
name: string, name: string,
userId: string; userId: string;
visibility: "PRIVATE" | "PUBLIC";
}; };
export type TSharedFolderWithTotalPairs = { export type TSharedFolderWithTotalPairs = {
id: number, id: number,
name: string, name: string,
userId: string, userId: string,
visibility: "PRIVATE" | "PUBLIC";
total: number; total: number;
}; };
@@ -21,3 +23,15 @@ export type TSharedPair = {
id: number; id: number;
folderId: number; folderId: number;
}; };
export type TPublicFolder = {
id: number;
name: string;
visibility: "PRIVATE" | "PUBLIC";
createdAt: Date;
userId: string;
userName: string | null;
userUsername: string | null;
totalPairs: number;
favoriteCount: number;
};