From 5dd70c6db8e2e8e8832e98feea951c2b6bef41c8 Mon Sep 17 00:00:00 2001 From: yoma Date: Thu, 9 Jul 2026 03:24:05 +0800 Subject: [PATCH] Add mobile resources browser --- apps/mobile/src/screens/WorkspaceScreen.tsx | 391 +++++++++++++++++++- 1 file changed, 390 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/screens/WorkspaceScreen.tsx b/apps/mobile/src/screens/WorkspaceScreen.tsx index 834b64ee..c451eb06 100644 --- a/apps/mobile/src/screens/WorkspaceScreen.tsx +++ b/apps/mobile/src/screens/WorkspaceScreen.tsx @@ -2,12 +2,16 @@ import { useEffect, useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient, type UseMutationResult } from "@tanstack/react-query"; import type { MemoFilterMode, MemoSortMode } from "@edgeever/client"; import { + Archive, BookOpen, Check, + ExternalLink, FileText, Folder, + HardDrive, History, Home, + Image as ImageIcon, LogOut, Merge, Pencil, @@ -26,6 +30,8 @@ import { ActivityIndicator, Alert, FlatList, + Image as RNImage, + Linking, Modal, Pressable, RefreshControl, @@ -36,7 +42,7 @@ import { View, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import type { MemoDetail, MemoRevision, MemoSummary, Notebook, TagSummary } from "@edgeever/shared"; +import type { MemoDetail, MemoRevision, MemoSummary, Notebook, ResourceListItem, TagSummary } from "@edgeever/shared"; import { useSession } from "../lib/session"; const ALL_NOTES_ID = "all"; @@ -59,6 +65,7 @@ export const WorkspaceScreen = () => { const [editingMemo, setEditingMemo] = useState(null); const [notebookManagerOpen, setNotebookManagerOpen] = useState(false); const [tagsManagerOpen, setTagsManagerOpen] = useState(false); + const [resourcesOpen, setResourcesOpen] = useState(false); const [revisionMemo, setRevisionMemo] = useState(null); const [selectedMemoIds, setSelectedMemoIds] = useState>(() => new Set()); const [selectionMoveOpen, setSelectionMoveOpen] = useState(false); @@ -417,6 +424,7 @@ export const WorkspaceScreen = () => { notebookCount={notebooks.length} memoCount={memoCount} onOpenNotebookManager={() => setNotebookManagerOpen(true)} + onOpenResources={() => setResourcesOpen(true)} onOpenTagsManager={() => setTagsManagerOpen(true)} /> ) : null} @@ -430,6 +438,7 @@ export const WorkspaceScreen = () => { onClose={closeDetail} onDelete={handleDeleteMemo} onEdit={setEditingMemo} + onOpenResources={() => setResourcesOpen(true)} onOpenRevisions={setRevisionMemo} onRestore={(memo) => restoreMemoMutation.mutate(memo)} onTogglePin={handleTogglePin} @@ -449,6 +458,7 @@ export const WorkspaceScreen = () => { setNotebookManagerOpen(false)} visible={notebookManagerOpen} /> setTagsManagerOpen(false)} visible={tagsManagerOpen} /> + setResourcesOpen(false)} visible={resourcesOpen} /> setRevisionMemo(null)} @@ -751,11 +761,13 @@ const SettingsView = ({ memoCount, notebookCount, onOpenNotebookManager, + onOpenResources, onOpenTagsManager, }: { memoCount: number; notebookCount: number; onOpenNotebookManager: () => void; + onOpenResources: () => void; onOpenTagsManager: () => void; }) => ( @@ -766,6 +778,9 @@ const SettingsView = ({ + + + @@ -1199,6 +1214,229 @@ const TagsManagerModal = ({ onClose, visible }: { onClose: () => void; visible: ); }; +type ResourceFilter = "all" | "image" | "document" | "other"; + +const DOCUMENT_MIME_TYPES = new Set([ + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "text/plain", + "text/markdown", + "application/json", + "application/xml", + "text/html", + "text/css", + "text/javascript", +]); + +const ResourcesModal = ({ + activeMemo, + onClose, + visible, +}: { + activeMemo: MemoDetail | null; + onClose: () => void; + visible: boolean; +}) => { + const { client } = useSession(); + const [searchText, setSearchText] = useState(""); + const [filter, setFilter] = useState("all"); + const [previewResource, setPreviewResource] = useState(null); + + const resourcesQuery = useQuery({ + queryKey: ["mobile", "resources"], + queryFn: async () => { + if (!client) { + throw new Error("Client is not ready"); + } + + return client.listResources(); + }, + enabled: Boolean(client && visible), + }); + + const resources = resourcesQuery.data?.resources ?? []; + const summary = resourcesQuery.data?.summary ?? { + totalCount: 0, + totalBytes: 0, + imageCount: 0, + attachmentCount: 0, + }; + const filteredResources = resources.filter((resource) => { + const isDocument = isDocumentResource(resource); + + if (filter === "image" && resource.kind !== "image") { + return false; + } + + if (filter === "document" && (!isDocument || resource.kind === "image")) { + return false; + } + + if (filter === "other" && (resource.kind === "image" || isDocument)) { + return false; + } + + const query = searchText.trim().toLowerCase(); + + if (!query) { + return true; + } + + return ( + (resource.filename || "").toLowerCase().includes(query) || + (resource.memoTitle || "").toLowerCase().includes(query) || + (resource.memoExcerpt || "").toLowerCase().includes(query) + ); + }); + + return ( + + + + + + + 资源库 + resourcesQuery.refetch()}> + {resourcesQuery.isFetching ? : } + + + + + + + {summary.totalCount} 个文件 + {formatBytes(summary.totalBytes)} + + + + {summary.imageCount} 张图片 + + {summary.attachmentCount} 个附件 + + + + + + {searchText ? ( + setSearchText("")}> + + + ) : null} + + + + setFilter("all")} /> + setFilter("image")} /> + setFilter("document")} /> + setFilter("other")} /> + + + + {activeMemo ? `当前笔记:${activeMemo.title?.trim() || activeMemo.excerpt || DEFAULT_MEMO_TITLE}` : "打开一条笔记后可作为资源上传目标"} + + + + {resourcesQuery.isLoading ? ( + + + + ) : filteredResources.length === 0 ? ( + + + {searchText || filter !== "all" ? "没有匹配资源" : "资源库为空"} + {searchText || filter !== "all" ? "调整筛选条件后再试" : "PWA 上传的图片和附件会显示在这里"} + + ) : ( + resource.id} + renderItem={({ item }) => openResource(item)} onPreview={() => setPreviewResource(item)} />} + refreshControl={ resourcesQuery.refetch()} refreshing={resourcesQuery.isFetching} tintColor="#0f172a" />} + /> + )} + + setPreviewResource(null)} /> + + + ); +}; + +const ResourceCard = ({ + onOpen, + onPreview, + resource, +}: { + onOpen: () => void; + onPreview: () => void; + resource: ResourceListItem; +}) => { + const source = resource.memoDeleted ? "已删除笔记" : resource.memoTitle || resource.memoExcerpt || resource.memoId; + const isImage = resource.kind === "image"; + + return ( + + + {isImage ? ( + + ) : ( + {getResourceIcon(resource)} + )} + + + + {resource.filename || resource.id} + + + {formatBytes(resource.byteSize)} · {resource.mimeType?.split("/")[1] || resource.kind} · {formatDate(resource.createdAt)} + + + 来源:{source} + + + + + + + ); +}; + +const ImagePreviewModal = ({ onClose, resource }: { onClose: () => void; resource: ResourceListItem | null }) => ( + + + + + {resource?.filename || "图片预览"} + + + + + + {resource ? : null} + {resource ? ( + openResource(resource)} style={styles.previewOpenButton}> + + 打开原文件 + + ) : null} + + +); + const RevisionHistoryModal = ({ memo, onClose, @@ -1351,6 +1589,7 @@ const MemoDetailModal = ({ onClose, onDelete, onEdit, + onOpenResources, onOpenRevisions, onRestore, onTogglePin, @@ -1364,6 +1603,7 @@ const MemoDetailModal = ({ onClose: () => void; onDelete: (memo: MemoDetail) => void; onEdit: (memo: MemoDetail) => void; + onOpenResources: () => void; onOpenRevisions: (memo: MemoDetail) => void; onRestore: (memo: MemoDetail) => void; onTogglePin: (memo: MemoDetail) => void; @@ -1408,6 +1648,9 @@ const MemoDetailModal = ({ onOpenRevisions(memo)}> + + + )} onDelete(memo)}> @@ -1861,6 +2104,36 @@ const formatDate = (value: string) => minute: "2-digit", }).format(new Date(value)); +const formatBytes = (bytes: number) => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / 1024 ** exponent; + + return `${exponent === 0 ? value.toFixed(0) : value.toFixed(value >= 10 ? 1 : 2)} ${units[exponent]}`; +}; + +const isDocumentResource = (resource: ResourceListItem) => DOCUMENT_MIME_TYPES.has(resource.mimeType || "") || resource.kind === "attachment"; + +const getResourceIcon = (resource: ResourceListItem) => { + const mime = (resource.mimeType || "").toLowerCase(); + + if (mime.startsWith("image/")) { + return ; + } + + return ; +}; + +const openResource = (resource: ResourceListItem) => { + Linking.openURL(resource.url).catch(() => { + Alert.alert("无法打开资源", "系统没有可用应用打开此链接。"); + }); +}; + const countChangedLines = (left: string, right: string) => { const leftLines = left.split("\n"); const rightLines = right.split("\n"); @@ -2004,6 +2277,35 @@ const styles = StyleSheet.create({ fontSize: 15, minHeight: 44, }, + assetsToolbar: { + backgroundColor: "#ffffff", + borderBottomColor: "#e2e8f0", + borderBottomWidth: 1, + gap: 10, + padding: 14, + }, + assetsSummary: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 7, + }, + assetsSummaryText: { + color: "#0f172a", + fontSize: 14, + fontWeight: "800", + }, + assetsSummaryMeta: { + color: "#64748b", + fontSize: 12, + fontWeight: "700", + }, + assetsHint: { + color: "#047857", + fontSize: 12, + fontWeight: "700", + lineHeight: 18, + }, sectionTitle: { color: "#0f172a", fontSize: 21, @@ -2074,6 +2376,10 @@ const styles = StyleSheet.create({ paddingBottom: 22, paddingHorizontal: 18, }, + assetList: { + padding: 18, + paddingBottom: 48, + }, emptyList: { flexGrow: 1, paddingBottom: 22, @@ -2153,6 +2459,41 @@ const styles = StyleSheet.create({ paddingHorizontal: 7, paddingVertical: 3, }, + resourceCard: { + alignItems: "center", + backgroundColor: "#ffffff", + borderColor: "#e2e8f0", + borderRadius: 8, + borderWidth: 1, + flexDirection: "row", + gap: 12, + marginBottom: 10, + padding: 10, + }, + resourceThumb: { + alignItems: "center", + backgroundColor: "#f8fafc", + borderColor: "#e2e8f0", + borderRadius: 8, + borderWidth: 1, + height: 58, + justifyContent: "center", + overflow: "hidden", + width: 58, + }, + resourceImage: { + height: "100%", + width: "100%", + }, + resourceFileIcon: { + alignItems: "center", + justifyContent: "center", + }, + resourceInfo: { + flex: 1, + gap: 4, + minWidth: 0, + }, centerState: { alignItems: "center", flex: 1, @@ -2495,4 +2836,52 @@ const styles = StyleSheet.create({ bottomNavTextActive: { color: "#0f172a", }, + previewBackdrop: { + alignItems: "center", + backgroundColor: "rgba(15, 23, 42, 0.92)", + flex: 1, + justifyContent: "center", + padding: 16, + }, + previewHeader: { + alignItems: "center", + backgroundColor: "#ffffff", + borderRadius: 8, + flexDirection: "row", + gap: 12, + left: 16, + padding: 10, + position: "absolute", + right: 16, + top: 54, + zIndex: 2, + }, + previewTitle: { + color: "#0f172a", + flex: 1, + fontSize: 14, + fontWeight: "800", + }, + previewImage: { + height: "72%", + width: "100%", + }, + previewOpenButton: { + alignItems: "center", + backgroundColor: "#0f172a", + borderColor: "rgba(255, 255, 255, 0.22)", + borderRadius: 8, + borderWidth: 1, + bottom: 42, + flexDirection: "row", + gap: 8, + minHeight: 46, + paddingHorizontal: 16, + position: "absolute", + }, + previewOpenText: { + color: "#ffffff", + fontSize: 14, + fontWeight: "800", + }, });