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)} />
+ }
+ sx={{
+ position: "fixed",
+ bottom: 90,
+ right: 24,
+ zIndex: 999,
+ }}
+ onClick={() => setShowTextPadDrawer(true)}
+ >
+ Open TextPad
+
+ >
+ )}
+
+
+
+
+
+ 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)}
+ />
+
+ }
+ fullWidth
+ sx={{ mt: 2 }}
+ onClick={handleSaveAndUpload}
+ >
+ Save & Upload
+
+
+
+ );
+};
+
+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 (
-
+ >
)}
+
+
+
+
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 && (
-
+
)}
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
+ />
+
+
+ Save & Upload Note
+
+
+
+ );
+};
+
+export default TextPadDrawer;