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