Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/sync-modify-to-main.yml
Original file line number Diff line number Diff line change
@@ -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"
36 changes: 36 additions & 0 deletions .github/workflows/sync-original-to-main.yml
Original file line number Diff line number Diff line change
@@ -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"
279 changes: 279 additions & 0 deletions Main.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
}}
>
{children}
</Box>
);
}

// Breadcrumb component
function PathBreadcrumb({
path,
onCwdChange,
}: {
path: string;
onCwdChange: (newCwd: string) => void;
}) {
const parts = path.replace(/\/$/, "").split("/");

return (
<Breadcrumbs separator="›" sx={{ padding: 1 }}>
<Button onClick={() => onCwdChange("")} sx={{ minWidth: 0, padding: 0 }}>
<HomeIcon />
</Button>
{parts.map((part, index) =>
index === parts.length - 1 ? (
<Typography key={index} color="text.primary">
{part}
</Typography>
) : (
<Link
key={index}
component="button"
onClick={() => {
onCwdChange(parts.slice(0, index + 1).join("/") + "/");
}}
>
{part}
</Link>
)
)}
</Breadcrumbs>
);
}

// DropZone wrapper
function DropZone({
children,
onDrop,
}: {
children: React.ReactNode;
onDrop: (files: FileList) => void;
}) {
const [dragging, setDragging] = useState(false);

return (
<Box
sx={{
flexGrow: 1,
overflowY: "auto",
backgroundColor: (theme) => 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}
</Box>
);
}

// Main Component
function Main({
search,
onError,
}: {
search: string;
onError: (error: Error) => void;
}) {
const [cwd, setCwd] = useState("");
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [multiSelected, setMultiSelected] = useState<string[] | null>(null);
const [showUploadDrawer, setShowUploadDrawer] = useState(false);
const [showTextPadDrawer, setShowTextPadDrawer] = useState(false);
const [lastUploadKey, setLastUploadKey] = useState<string | null>(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 && <PathBreadcrumb path={cwd} onCwdChange={setCwd} />}

{loading ? (
<Centered>
<CircularProgress />
</Centered>
) : (
<DropZone
onDrop={(files) => {
uploadEnqueue(
...Array.from(files).map((file) => ({ file, basedir: cwd }))
);
}}
>
<FileGrid
files={filteredFiles}
onCwdChange={(newCwd: string) => setCwd(newCwd)}
multiSelected={multiSelected}
onMultiSelect={handleMultiSelect}
emptyMessage={<Centered>No files or folders</Centered>}
/>
</DropZone>
)}

{multiSelected === null && (
<>
<UploadFab onClick={() => setShowUploadDrawer(true)} />
<Button
variant="contained"
startIcon={<NoteAddIcon />}
sx={{
position: "fixed",
bottom: 90,
right: 24,
zIndex: 999,
}}
onClick={() => setShowTextPadDrawer(true)}
>
Open TextPad
</Button>
</>
)}

<UploadDrawer
open={showUploadDrawer}
setOpen={setShowUploadDrawer}
cwd={cwd}
onUpload={fetchFiles}
/>

<TextPadDrawer
open={showTextPadDrawer}
setOpen={setShowTextPadDrawer}
cwd={cwd}
onUpload={fetchFiles}
/>

<MultiSelectToolbar
multiSelected={multiSelected}
onClose={() => 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;
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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://<your-domain.com>/webdav` and use the username and password you set.

Expand Down
Loading