feat(folders): 添加公开文件夹和收藏功能
- 新增文件夹可见性控制(公开/私有) - 添加公开文件夹浏览和搜索 - 实现文件夹收藏功能 - 新增 FolderFavorite 数据模型 - 更新 Prisma 至 7.4.2 - 添加相关 i18n 翻译
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "您不是此文件夹的所有者",
|
||||
|
||||
@@ -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
373
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
pairs Pair[]
|
||||
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")
|
||||
|
||||
@@ -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">
|
||||
<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">
|
||||
{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,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 [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")} />
|
||||
|
||||
{/* 新建文件夹按钮 */}
|
||||
<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);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</LightButton>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{userId && (
|
||||
<button
|
||||
onClick={() => setActiveTab("my")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === "my"
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("myFolders")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setActiveTab("public")}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === "public"
|
||||
? "bg-primary-500 text-white"
|
||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{t("publicFolders")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "public" && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "my" && userId && (
|
||||
<LightButton
|
||||
onClick={handleCreateFolder}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</LightButton>
|
||||
)}
|
||||
|
||||
{/* 文件夹列表 */}
|
||||
<div className="mt-4">
|
||||
<CardList>
|
||||
{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>
|
||||
{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" />
|
||||
</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}
|
||||
/>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
// 文件夹卡片列表
|
||||
folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
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}
|
||||
refresh={updateFolders}
|
||||
currentUserId={userId ?? undefined}
|
||||
onFavoriteChange={handleSearch}
|
||||
/>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</CardList>
|
||||
</div>
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user