diff --git a/.github/workflows/sync-modify-to-main.yml b/.github/workflows/sync-modify-to-main.yml new file mode 100644 index 0000000..bcfb4b0 --- /dev/null +++ b/.github/workflows/sync-modify-to-main.yml @@ -0,0 +1,36 @@ +name: Sync Modify to Main + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync-modify-to-main: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + + - name: Sync Modify to Main + run: | + echo "📦 正在从 Modify 同步到 main" + git fetch origin Modify + git fetch origin main + git checkout main + git merge origin/Modify --no-ff -m "🔄 Sync: Merge Modify into main" + git push origin main + + - name: Success message + run: | + echo "✅ 成功将 Modify 同步到 main" diff --git a/.github/workflows/sync-original-to-main.yml b/.github/workflows/sync-original-to-main.yml new file mode 100644 index 0000000..165911c --- /dev/null +++ b/.github/workflows/sync-original-to-main.yml @@ -0,0 +1,36 @@ +name: Sync Original to Main + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync-original-to-main: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure Git + run: | + git config user.name "GitHub Actions Bot" + git config user.email "actions@github.com" + + - name: Sync Original to Main + run: | + echo "📦 正在从 Original 同步到 main" + git fetch origin Original + git fetch origin main + git checkout main + git merge origin/Original --no-ff -m "🔄 Sync: Merge Original into main" + git push origin main + + - name: Success message + run: | + echo "✅ 成功将 Original 同步到 main" diff --git a/Main.tsx b/Main.tsx new file mode 100644 index 0000000..ab10f01 --- /dev/null +++ b/Main.tsx @@ -0,0 +1,279 @@ +// Main.tsx +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { + Box, + Breadcrumbs, + Button, + CircularProgress, + Link, + Typography, +} from "@mui/material"; +import { Home as HomeIcon, NoteAdd as NoteAddIcon } from "@mui/icons-material"; + +import FileGrid, { encodeKey, FileItem, isDirectory } from "./FileGrid"; +import MultiSelectToolbar from "./MultiSelectToolbar"; +import UploadDrawer, { UploadFab } from "./UploadDrawer"; +import TextPadDrawer from "./TextPadDrawer"; +import { copyPaste, fetchPath } from "./app/transfer"; +import { useTransferQueue, useUploadEnqueue } from "./app/transferQueue"; + +// Centered helper +function Centered({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +// Breadcrumb component +function PathBreadcrumb({ + path, + onCwdChange, +}: { + path: string; + onCwdChange: (newCwd: string) => void; +}) { + const parts = path.replace(/\/$/, "").split("/"); + + return ( + + + {parts.map((part, index) => + index === parts.length - 1 ? ( + + {part} + + ) : ( + { + onCwdChange(parts.slice(0, index + 1).join("/") + "/"); + }} + > + {part} + + ) + )} + + ); +} + +// DropZone wrapper +function DropZone({ + children, + onDrop, +}: { + children: React.ReactNode; + onDrop: (files: FileList) => void; +}) { + const [dragging, setDragging] = useState(false); + + return ( + theme.palette.background.default, + filter: dragging ? "brightness(0.9)" : "none", + transition: "filter 0.2s", + }} + onDragEnter={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + onDrop(e.dataTransfer.files); + setDragging(false); + }} + > + {children} + + ); +} + +// Main Component +function Main({ + search, + onError, +}: { + search: string; + onError: (error: Error) => void; +}) { + const [cwd, setCwd] = useState(""); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [multiSelected, setMultiSelected] = useState(null); + const [showUploadDrawer, setShowUploadDrawer] = useState(false); + const [showTextPadDrawer, setShowTextPadDrawer] = useState(false); + const [lastUploadKey, setLastUploadKey] = useState(null); + + const transferQueue = useTransferQueue(); + const uploadEnqueue = useUploadEnqueue(); + + const fetchFiles = useCallback(() => { + fetchPath(cwd) + .then((files) => { + setFiles(files); + setMultiSelected(null); + }) + .catch(onError) + .finally(() => setLoading(false)); + }, [cwd, onError]); + + useEffect(() => setLoading(true), [cwd]); + + useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + useEffect(() => { + if (!transferQueue.length) return; + const lastFile = transferQueue[transferQueue.length - 1]; + if (["pending", "in-progress"].includes(lastFile.status)) { + setLastUploadKey(lastFile.remoteKey); + } else if (lastUploadKey) { + fetchFiles(); + setLastUploadKey(null); + } + }, [cwd, fetchFiles, lastUploadKey, transferQueue]); + + const filteredFiles = useMemo( + () => + (search + ? files.filter((file) => + file.key.toLowerCase().includes(search.toLowerCase()) + ) + : files + ).sort((a, b) => (isDirectory(a) ? -1 : isDirectory(b) ? 1 : 0)), + [files, search] + ); + + const handleMultiSelect = useCallback((key: string) => { + setMultiSelected((prev) => { + if (prev === null) return [key]; + if (prev.includes(key)) { + const updated = prev.filter((k) => k !== key); + return updated.length ? updated : null; + } + return [...prev, key]; + }); + }, []); + + return ( + <> + {cwd && } + + {loading ? ( + + + + ) : ( + { + uploadEnqueue( + ...Array.from(files).map((file) => ({ file, basedir: cwd })) + ); + }} + > + setCwd(newCwd)} + multiSelected={multiSelected} + onMultiSelect={handleMultiSelect} + emptyMessage={No files or folders} + /> + + )} + + {multiSelected === null && ( + <> + setShowUploadDrawer(true)} /> + + + )} + + + + + + setMultiSelected(null)} + onDownload={() => { + if (multiSelected?.length !== 1) return; + const a = document.createElement("a"); + a.href = `/webdav/${encodeKey(multiSelected[0])}`; + a.download = multiSelected[0].split("/").pop()!; + a.click(); + }} + onRename={async () => { + if (multiSelected?.length !== 1) return; + const newName = window.prompt("Rename to:"); + if (!newName) return; + await copyPaste(multiSelected[0], cwd + newName, true); + fetchFiles(); + }} + onDelete={async () => { + if (!multiSelected?.length) return; + const filenames = multiSelected + .map((key) => key.replace(/\/$/, "").split("/").pop()) + .join("\n"); + const confirmMessage = "Delete the following file(s) permanently?"; + if (!window.confirm(`${confirmMessage}\n${filenames}`)) return; + for (const key of multiSelected) + await fetch(`/webdav/${encodeKey(key)}`, { method: "DELETE" }); + fetchFiles(); + }} + onShare={() => { + if (multiSelected?.length !== 1) return; + const url = new URL( + `/webdav/${encodeKey(multiSelected[0])}`, + window.location.href + ); + navigator.share({ url: url.toString() }); + }} + /> + + ); +} + +export default Main; diff --git a/README.md b/README.md index 4ce92b5..261b076 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Before starting, you should make sure that Steps: 1. Fork this project and connect your fork with Cloudflare Pages - - Select `Create React App` framework preset + - Select `Docusaurus` framework preset - Set `WEBDAV_USERNAME` and `WEBDAV_PASSWORD` - (Optional) Set `WEBDAV_PUBLIC_READ` to `1` to enable public read 2. After initial deployment, bind your R2 bucket to `BUCKET` variable @@ -42,7 +42,7 @@ npx wrangler pages deploy build ### WebDAV endpoint -You can use any client (such as [BD File Manager](https://play.google.com/store/apps/details?id=com.liuzho.file.explorer)) +You can use any client (such as [Cx File Explorer](https://play.google.com/store/apps/details?id=com.cxinventor.file.explorer), [BD File Manager](https://play.google.com/store/apps/details?id=com.liuzho.file.explorer)) that supports the WebDAV protocol to access your files. Fill the endpoint URL as `https:///webdav` and use the username and password you set. diff --git a/TextPadDrawer.tsx b/TextPadDrawer.tsx new file mode 100644 index 0000000..d3586c4 --- /dev/null +++ b/TextPadDrawer.tsx @@ -0,0 +1,87 @@ +// TextPadDrawer.tsx +import React, { useState } from "react"; +import { + Drawer, + IconButton, + TextField, + Box, + Typography, + Button, +} from "@mui/material"; +import { Close as CloseIcon, Save as SaveIcon } from "@mui/icons-material"; +import { useUploadEnqueue } from "./app/transferQueue"; + +type TextPadDrawerProps = { + open: boolean; + setOpen: (open: boolean) => void; + cwd: string; + onUpload: () => void; +}; + +const TextPadDrawer = ({ open, setOpen, cwd, onUpload }: TextPadDrawerProps) => { + const [noteTitle, setNoteTitle] = useState(""); + const [noteContent, setNoteContent] = useState(""); + + const uploadEnqueue = useUploadEnqueue(); + + const handleSaveAndUpload = async () => { + if (!noteTitle.trim() || !noteContent.trim()) return; + + const file = new File([noteContent], `${noteTitle}.txt`, { + type: "text/plain", + }); + + uploadEnqueue({ file, basedir: cwd }); + + // Reset fields and close drawer + setNoteTitle(""); + setNoteContent(""); + setOpen(false); + onUpload(); // Refresh file list + }; + + return ( + setOpen(false)}> + + + TextPad + setOpen(false)}> + + + + + setNoteTitle(e.target.value)} + /> + + setNoteContent(e.target.value)} + /> + + + + + ); +}; + +export default TextPadDrawer; diff --git a/functions/webdav/[[path]].ts b/functions/webdav/[[path]].ts index f9077e8..fbc3e42 100644 --- a/functions/webdav/[[path]].ts +++ b/functions/webdav/[[path]].ts @@ -12,7 +12,10 @@ import { handleRequestPost } from "./post"; async function handleRequestOptions() { return new Response(null, { - headers: { Allow: Object.keys(HANDLERS).join(", ") }, + headers: { + Allow: Object.keys(HANDLERS).join(", "), + DAV: "1", + }, }); } @@ -45,7 +48,7 @@ export const onRequest: PagesFunction<{ if (request.method === "OPTIONS") return handleRequestOptions(); const skipAuth = - env.WEBDAV_PUBLIC_READ && + env.WEBDAV_PUBLIC_READ === "1" && ["GET", "HEAD", "PROPFIND"].includes(request.method); if (!skipAuth) { diff --git a/public/robots.txt b/public/robots.txt index e9e57dc..3dad881 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,3 +1,56 @@ -# https://www.robotstxt.org/robotstxt.html -User-agent: * -Disallow: +# As a condition of accessing this website, you agree to abide by the following +# content signals: + +# (a) If a content-signal = yes, you may collect content for the corresponding +# use. +# (b) If a content-signal = no, you may not collect content for the +# corresponding use. +# (c) If the website operator does not include a content signal for a +# corresponding use, the website operator neither grants nor restricts +# permission via content signal with respect to the corresponding use. + +# The content signals and their meanings are: + +# search: building a search index and providing search results (e.g., returning +# hyperlinks and short excerpts from your website's contents). Search does not +# include providing AI-generated search summaries. +# ai-input: inputting content into one or more AI models (e.g., retrieval +# augmented generation, grounding, or other real-time taking of content for +# generative AI search answers). +# ai-train: training or fine-tuning AI models. + +# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF +# RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT +# AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET. + +# BEGIN Cloudflare Managed content + +User-Agent: * +Content-signal: search=yes,ai-train=no +Allow: / + +User-agent: Amazonbot +Disallow: / + +User-agent: Applebot-Extended +Disallow: / + +User-agent: Bytespider +Disallow: / + +User-agent: CCBot +Disallow: / + +User-agent: ClaudeBot +Disallow: / + +User-agent: Google-Extended +Disallow: / + +User-agent: GPTBot +Disallow: / + +User-agent: meta-externalagent +Disallow: / + +# END Cloudflare Managed Content diff --git a/src/FileGrid.tsx b/src/FileGrid.tsx index 755031a..c48a8d0 100644 --- a/src/FileGrid.tsx +++ b/src/FileGrid.tsx @@ -49,24 +49,24 @@ function FileGrid({ {files.map((file) => ( { + onClick={() => { if (multiSelected !== null) { onMultiSelect(file.key); - event.preventDefault(); } else if (isDirectory(file)) { onCwdChange(file.key + "/"); - event.preventDefault(); - } + } else + window.open( + `/webdav/${encodeKey(file.key)}`, + "_blank", + "noopener,noreferrer" + ); }} onContextMenu={(e) => { e.preventDefault(); onMultiSelect(file.key); }} + sx={{ userSelect: "none" }} > {file.customMetadata?.thumbnail ? ( diff --git a/src/Main.tsx b/src/Main.tsx index 19a3f1e..ab10f01 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -1,4 +1,5 @@ -import { Home as HomeIcon } from "@mui/icons-material"; +// Main.tsx +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Box, Breadcrumbs, @@ -7,14 +8,16 @@ import { Link, Typography, } from "@mui/material"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Home as HomeIcon, NoteAdd as NoteAddIcon } from "@mui/icons-material"; import FileGrid, { encodeKey, FileItem, isDirectory } from "./FileGrid"; import MultiSelectToolbar from "./MultiSelectToolbar"; import UploadDrawer, { UploadFab } from "./UploadDrawer"; +import TextPadDrawer from "./TextPadDrawer"; import { copyPaste, fetchPath } from "./app/transfer"; import { useTransferQueue, useUploadEnqueue } from "./app/transferQueue"; +// Centered helper function Centered({ children }: { children: React.ReactNode }) { return ( - {parts.map((part, index) => @@ -71,6 +69,7 @@ function PathBreadcrumb({ ); } +// DropZone wrapper function DropZone({ children, onDrop, @@ -89,18 +88,18 @@ function DropZone({ filter: dragging ? "brightness(0.9)" : "none", transition: "filter 0.2s", }} - onDragEnter={(event) => { - event.preventDefault(); + onDragEnter={(e) => { + e.preventDefault(); setDragging(true); }} - onDragOver={(event) => { - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; }} onDragLeave={() => setDragging(false)} - onDrop={(event) => { - event.preventDefault(); - onDrop(event.dataTransfer.files); + onDrop={(e) => { + e.preventDefault(); + onDrop(e.dataTransfer.files); setDragging(false); }} > @@ -109,6 +108,7 @@ function DropZone({ ); } +// Main Component function Main({ search, onError, @@ -116,11 +116,12 @@ function Main({ search: string; onError: (error: Error) => void; }) { - const [cwd, setCwd] = React.useState(""); + const [cwd, setCwd] = useState(""); const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); const [multiSelected, setMultiSelected] = useState(null); const [showUploadDrawer, setShowUploadDrawer] = useState(false); + const [showTextPadDrawer, setShowTextPadDrawer] = useState(false); const [lastUploadKey, setLastUploadKey] = useState(null); const transferQueue = useTransferQueue(); @@ -145,9 +146,9 @@ function Main({ useEffect(() => { if (!transferQueue.length) return; const lastFile = transferQueue[transferQueue.length - 1]; - if (["pending", "in-progress"].includes(lastFile.status)) + if (["pending", "in-progress"].includes(lastFile.status)) { setLastUploadKey(lastFile.remoteKey); - else if (lastUploadKey) { + } else if (lastUploadKey) { fetchFiles(); setLastUploadKey(null); } @@ -165,27 +166,27 @@ function Main({ ); const handleMultiSelect = useCallback((key: string) => { - setMultiSelected((multiSelected) => { - if (multiSelected === null) { - return [key]; - } else if (multiSelected.includes(key)) { - const newSelected = multiSelected.filter((k) => k !== key); - return newSelected.length ? newSelected : null; + setMultiSelected((prev) => { + if (prev === null) return [key]; + if (prev.includes(key)) { + const updated = prev.filter((k) => k !== key); + return updated.length ? updated : null; } - return [...multiSelected, key]; + return [...prev, key]; }); }, []); return ( - + <> {cwd && } + {loading ? ( ) : ( { + onDrop={(files) => { uploadEnqueue( ...Array.from(files).map((file) => ({ file, basedir: cwd })) ); @@ -200,15 +201,40 @@ function Main({ /> )} + {multiSelected === null && ( - setShowUploadDrawer(true)} /> + <> + setShowUploadDrawer(true)} /> + + )} + + + + setMultiSelected(null)} @@ -237,8 +263,16 @@ function Main({ await fetch(`/webdav/${encodeKey(key)}`, { method: "DELETE" }); fetchFiles(); }} + onShare={() => { + if (multiSelected?.length !== 1) return; + const url = new URL( + `/webdav/${encodeKey(multiSelected[0])}`, + window.location.href + ); + navigator.share({ url: url.toString() }); + }} /> - + ); } diff --git a/src/MultiSelectToolbar.tsx b/src/MultiSelectToolbar.tsx index 186419a..324b37b 100644 --- a/src/MultiSelectToolbar.tsx +++ b/src/MultiSelectToolbar.tsx @@ -13,12 +13,14 @@ function MultiSelectToolbar({ onDownload, onRename, onDelete, + onShare, }: { multiSelected: string[] | null; onClose: () => void; onDownload: () => void; onRename: () => void; onDelete: () => void; + onShare: () => void; }) { const [anchorEl, setAnchorEl] = useState(null); @@ -69,7 +71,7 @@ function MultiSelectToolbar({ {multiSelected.length === 1 && ( Rename - Share + Share )} diff --git a/src/TextPadDrawer.tsx b/src/TextPadDrawer.tsx new file mode 100644 index 0000000..5154bf8 --- /dev/null +++ b/src/TextPadDrawer.tsx @@ -0,0 +1,82 @@ +// TextPadDrawer.tsx +import React, { useState } from "react"; +import { + Box, + Button, + Drawer, + TextField, + Typography, + IconButton, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import { useUploadEnqueue } from "./app/transferQueue"; + +interface TextPadDrawerProps { + open: boolean; + setOpen: (open: boolean) => void; + cwd: string; + onUpload: () => void; +} + +const TextPadDrawer: React.FC = ({ + open, + setOpen, + cwd, + onUpload, +}) => { + const [noteText, setNoteText] = useState(""); + const [noteName, setNoteName] = useState("note.txt"); + const uploadEnqueue = useUploadEnqueue(); + + const handleSaveNote = () => { + const fileBlob = new Blob([noteText], { type: "text/plain" }); + const file = new File([fileBlob], noteName, { type: "text/plain" }); + uploadEnqueue({ file, basedir: cwd }); + onUpload(); // Refresh file list after upload + setOpen(false); // Close drawer + setNoteText(""); // Reset + setNoteName("note.txt"); + }; + + return ( + setOpen(false)}> + + + TextPad + setOpen(false)}> + + + + + setNoteName(e.target.value)} + fullWidth + sx={{ mb: 2 }} + /> + + setNoteText(e.target.value)} + fullWidth + /> + + + + + ); +}; + +export default TextPadDrawer;