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",
"folderInfo": "ID: {id} • {totalPairs} pairs",
"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": {
"unauthorized": "You are not the owner of this folder",

View File

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

View File

@@ -11,8 +11,8 @@
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0",
"@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "7.4.2",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.10",
"class-variance-authority": "^0.7.1",
@@ -43,7 +43,7 @@
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"eslint-plugin-react": "^7.37.5",
"prisma": "^7.2.0",
"prisma": "^7.4.2",
"tailwindcss": "^4.1.18",
"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[]
dictionaryLookUps DictionaryLookUp[]
folders Folder[]
folderFavorites FolderFavorite[]
sessions Session[]
translationHistories TranslationHistory[]
@@ -91,19 +92,42 @@ model Pair {
@@map("pairs")
}
enum Visibility {
PRIVATE
PUBLIC
}
model Folder {
id Int @id @default(autoincrement())
name String
userId String @map("user_id")
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
pairs Pair[]
favorites FolderFavorite[]
@@index([userId])
@@index([visibility])
@@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 {
id Int @id @default(autoincrement())
userId String? @map("user_id")

View File

@@ -5,6 +5,10 @@ import {
Folder as Fd,
FolderPen,
FolderPlus,
Globe,
Heart,
Lock,
Search,
Trash2,
} from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button";
@@ -15,18 +19,41 @@ import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { actionCreateFolder, actionDeleteFolderById, actionGetFoldersWithTotalPairsByUserId, actionRenameFolderById } from "@/modules/folder/folder-aciton";
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
import {
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 {
folder: TSharedFolderWithTotalPairs;
refresh: () => void;
showVisibility?: boolean;
}
const FolderCard = ({ folder, refresh }: FolderProps) => {
const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => {
const router = useRouter();
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 (
<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"
@@ -40,7 +67,19 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
{showVisibility && (
<span className="flex items-center gap-1 text-xs text-gray-400">
{folder.visibility === "PUBLIC" ? (
<Globe size={12} />
) : (
<Lock size={12} />
)}
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
</span>
)}
</div>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
id: folder.id,
@@ -52,10 +91,22 @@ const FolderCard = ({ folder, refresh }: FolderProps) => {
</div>
<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
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
const newName = prompt("Input a new name.")?.trim();
const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) {
actionRenameFolderById(folder.id, newName)
.then(result => {
@@ -97,74 +148,227 @@ 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 [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>(
[],
);
const [loading, setLoading] = useState(false);
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
useEffect(() => {
setLoading(true);
actionGetFoldersWithTotalPairsByUserId(userId)
.then((folders) => {
if (folders.success && folders.data) {
setFolders(folders.data);
setLoading(false);
if (currentUserId) {
actionCheckFavorite(folder.id).then(result => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onFavoriteChange?.();
} else {
toast.error(result.message);
}
};
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/folders/${folder.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("publicFolderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Heart
size={14}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
<span>{favoriteCount}</span>
</div>
<CircleButton
onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")}
>
<Heart
size={18}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
</CircleButton>
<ChevronRight size={20} className="text-gray-400" />
</div>
</div>
);
};
interface FoldersClientProps {
userId: string | null;
initialPublicFolders: TPublicFolder[];
}
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]);
const updateFolders = async () => {
if (!userId) return;
setLoading(true);
await actionGetFoldersWithTotalPairsByUserId(userId)
.then(async result => {
if (!result.success) toast.error(result.message);
else await actionGetFoldersWithTotalPairsByUserId(userId)
.then((folders) => {
if (folders.success && folders.data) {
setFolders(folders.data);
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
if (result.success && result.data) {
setFolders(result.data);
}
});
});
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 (
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
{/* 新建文件夹按钮 */}
<div className="flex items-center gap-2 mb-4">
{userId && (
<button
onClick={() => setActiveTab("my")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === "my"
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t("myFolders")}
</button>
)}
<button
onClick={() => setActiveTab("public")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === "public"
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t("publicFolders")}
</button>
</div>
{activeTab === "public" && (
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<CircleButton onClick={handleSearch}>
<Search size={18} />
</CircleButton>
</div>
)}
{activeTab === "my" && userId && (
<LightButton
onClick={async () => {
const folderName = prompt(t("enterFolderName"));
if (!folderName) return;
setLoading(true);
try {
await actionCreateFolder(userId, folderName)
.then(result => {
if (result.success) {
updateFolders();
} else {
toast.error(result.message);
}
});
} finally {
setLoading(false);
}
}}
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>
{folders.length === 0 ? (
// 空状态
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : activeTab === "my" && userId ? (
folders.length === 0 ? (
<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" />
@@ -172,7 +376,6 @@ export function FoldersClient({ userId }: { userId: string; }) {
<p className="text-sm">{t("noFoldersYet")}</p>
</div>
) : (
// 文件夹卡片列表
folders
.toSorted((a, b) => a.id - b.id)
.map((folder) => (
@@ -180,8 +383,28 @@ export function FoldersClient({ userId }: { userId: string; }) {
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>

View File

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

View File

@@ -1,12 +1,29 @@
import { auth } from "@/auth";
import { FoldersClient } from "./FoldersClient";
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
export default async function FoldersPage() {
const session = await auth.api.getSession(
{ 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 { headers } from "next/headers";
import { ValidateError } from "@/lib/errors";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetUserIdByFolderId, repoRenameFolderById, repoUpdatePairById } from "./folder-repository";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } from "./folder-repository";
import { validate } from "@/utils/validate";
import z from "zod";
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> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
@@ -20,9 +18,6 @@ async function checkFolderOwnership(folderId: number): Promise<boolean> {
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> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) return false;
@@ -52,7 +47,6 @@ export async function actionGetPairsByFolderId(folderId: number) {
export async function actionUpdatePairById(id: number, dto: ActionInputUpdatePairById) {
try {
// Check ownership
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
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) {
try {
// Check ownership
const isOwner = await checkFolderOwnership(folderId);
if (!isOwner) {
return {
@@ -119,7 +128,6 @@ export async function actionDeleteFolderById(folderId: number) {
export async function actionDeletePairById(id: number) {
try {
// Check ownership
const isOwner = await checkPairOwnership(id);
if (!isOwner) {
return {
@@ -176,7 +184,6 @@ export async function actionGetFoldersByUserId(userId: string) {
export async function actionCreatePair(dto: ActionInputCreatePair) {
try {
// Check ownership
const isOwner = await checkFolderOwnership(dto.folderId);
if (!isOwner) {
return {
@@ -238,7 +245,6 @@ export async function actionCreateFolder(userId: string, folderName: string) {
export async function actionRenameFolderById(id: number, newName: string) {
try {
// Check ownership
const isOwner = await checkFolderOwnership(id);
if (!isOwner) {
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,
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 {
name: string;
userId: string;
@@ -21,3 +23,51 @@ export interface RepoInputUpdatePair {
ipa1?: 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 { 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) {
return (await prisma.pair.create({
@@ -63,7 +76,8 @@ export async function repoGetFoldersByUserId(userId: string) {
return {
id: v.id,
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,
name: folder.name,
userId: folder.userId,
visibility: folder.visibility,
total: folder._count?.pairs ?? 0,
createdAt: folder.createdAt,
}));
@@ -134,3 +149,126 @@ export async function repoGetFolderIdByPairId(pairId: number) {
});
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,
name: string,
userId: string;
visibility: "PRIVATE" | "PUBLIC";
};
export type TSharedFolderWithTotalPairs = {
id: number,
name: string,
userId: string,
visibility: "PRIVATE" | "PUBLIC";
total: number;
};
@@ -21,3 +23,15 @@ export type TSharedPair = {
id: 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;
};