mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Add file upload and download (zip for directories)
This commit is contained in:
1 parent
0d564982dc
commit
da4e02472f
7 files changed
+383
-42
No files matched your search
@@ -124,6 +124,28 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
|
|||||||
RenameDirectoryNote(DestinationPath, OldName)
|
RenameDirectoryNote(DestinationPath, OldName)
|
||||||
|
|
||||||
|
|
||||||
|
def SaveUploadedFile(
|
||||||
|
RootName: str, DirectoryRelativePath: str, FileName: str, FileBytes: bytes
|
||||||
|
) -> str:
|
||||||
|
DirectoryPath = ResolvePath(RootName, DirectoryRelativePath)
|
||||||
|
|
||||||
|
if not DirectoryPath.is_dir():
|
||||||
|
raise NotADirectoryError(f"{DirectoryPath} is not a directory.")
|
||||||
|
|
||||||
|
SafeName = Path(FileName).name
|
||||||
|
|
||||||
|
if not SafeName or SafeName in (".", ".."):
|
||||||
|
raise ValueError("Invalid file name.")
|
||||||
|
|
||||||
|
TargetPath = DirectoryPath / SafeName
|
||||||
|
|
||||||
|
if TargetPath.exists():
|
||||||
|
raise FileExistsError(f"{SafeName} already exists.")
|
||||||
|
|
||||||
|
TargetPath.write_bytes(FileBytes)
|
||||||
|
return SafeName
|
||||||
|
|
||||||
|
|
||||||
def DeleteEntry(RootName: str, RelativePath: str) -> None:
|
def DeleteEntry(RootName: str, RelativePath: str) -> None:
|
||||||
EntryPath = ResolvePath(RootName, RelativePath)
|
EntryPath = ResolvePath(RootName, RelativePath)
|
||||||
|
|
||||||
|
|||||||
+111
@@ -2,8 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
ApplicationDirectory = Path(__file__).resolve().parent
|
ApplicationDirectory = Path(__file__).resolve().parent
|
||||||
if str(ApplicationDirectory) not in sys.path:
|
if str(ApplicationDirectory) not in sys.path:
|
||||||
@@ -12,6 +16,7 @@ if str(ApplicationDirectory) not in sys.path:
|
|||||||
from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse
|
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse
|
||||||
|
from starlette.background import BackgroundTask
|
||||||
|
|
||||||
from Auth import (
|
from Auth import (
|
||||||
CreateSession,
|
CreateSession,
|
||||||
@@ -34,6 +39,7 @@ from Explorer import (
|
|||||||
ListDirectory,
|
ListDirectory,
|
||||||
ListRoots,
|
ListRoots,
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
|
SaveUploadedFile,
|
||||||
)
|
)
|
||||||
from Groups import (
|
from Groups import (
|
||||||
CreateGroup,
|
CreateGroup,
|
||||||
@@ -429,6 +435,36 @@ def PostTreeEndpoint(
|
|||||||
return {"Created": True}
|
return {"Created": True}
|
||||||
|
|
||||||
|
|
||||||
|
@Application.post("/api/upload")
|
||||||
|
async def PostUploadEndpoint(
|
||||||
|
Root: str,
|
||||||
|
Path: str,
|
||||||
|
Files: list[UploadFile] = File(...),
|
||||||
|
CurrentUser: dict = Depends(GetCurrentUser),
|
||||||
|
) -> dict:
|
||||||
|
RequirePathAccessible(Root, Path, CurrentUser)
|
||||||
|
|
||||||
|
SavedNames: list[str] = []
|
||||||
|
Errors: list[str] = []
|
||||||
|
|
||||||
|
for UploadedFile in Files:
|
||||||
|
DisplayName = UploadedFile.filename or "upload"
|
||||||
|
|
||||||
|
try:
|
||||||
|
FileBytes = await UploadedFile.read()
|
||||||
|
SavedName = SaveUploadedFile(Root, Path, DisplayName, FileBytes)
|
||||||
|
SavedNames.append(SavedName)
|
||||||
|
except (ValueError, FileExistsError, NotADirectoryError) as Error:
|
||||||
|
Errors.append(f"{DisplayName}: {Error}")
|
||||||
|
except OSError as Error:
|
||||||
|
Errors.append(f"{DisplayName}: Could not save file ({Error})")
|
||||||
|
|
||||||
|
if Errors and not SavedNames:
|
||||||
|
raise HTTPException(status_code=400, detail="; ".join(Errors))
|
||||||
|
|
||||||
|
return {"Saved": SavedNames, "Errors": Errors}
|
||||||
|
|
||||||
|
|
||||||
class MoveEntryRequest(BaseModel):
|
class MoveEntryRequest(BaseModel):
|
||||||
NewName: str | None = None
|
NewName: str | None = None
|
||||||
DestinationPath: str | None = None
|
DestinationPath: str | None = None
|
||||||
@@ -622,6 +658,81 @@ def GetRawFileEndpoint(
|
|||||||
return StreamingResponse(IterateFile(), media_type=MimeType)
|
return StreamingResponse(IterateFile(), media_type=MimeType)
|
||||||
|
|
||||||
|
|
||||||
|
def _AddDirectoryToZip(
|
||||||
|
ZipHandle: zipfile.ZipFile,
|
||||||
|
RootName: str,
|
||||||
|
RelativePath: str,
|
||||||
|
Username: str,
|
||||||
|
UserGroups: list[str],
|
||||||
|
ArcPrefix: str,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
Entries = ListDirectory(RootName, RelativePath, False, False)
|
||||||
|
except (ValueError, NotADirectoryError, FileNotFoundError):
|
||||||
|
return
|
||||||
|
|
||||||
|
for Entry in Entries:
|
||||||
|
EntryRelativePath = f"{RelativePath}/{Entry['Name']}" if RelativePath else Entry["Name"]
|
||||||
|
|
||||||
|
if not IsPathAccessible(RootName, EntryRelativePath, Username, UserGroups):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ArcName = f"{ArcPrefix}/{Entry['Name']}" if ArcPrefix else Entry["Name"]
|
||||||
|
|
||||||
|
if Entry["IsDirectory"]:
|
||||||
|
_AddDirectoryToZip(ZipHandle, RootName, EntryRelativePath, Username, UserGroups, ArcName)
|
||||||
|
else:
|
||||||
|
ZipHandle.write(ResolvePath(RootName, EntryRelativePath), ArcName)
|
||||||
|
|
||||||
|
|
||||||
|
@Application.get("/api/download")
|
||||||
|
def GetDownloadEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)):
|
||||||
|
RequirePathAccessible(Root, Path, CurrentUser)
|
||||||
|
|
||||||
|
try:
|
||||||
|
EntryPath = ResolvePath(Root, Path)
|
||||||
|
except ValueError as Error:
|
||||||
|
raise HTTPException(status_code=400, detail=str(Error))
|
||||||
|
|
||||||
|
if not EntryPath.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Not found.")
|
||||||
|
|
||||||
|
Username = CurrentUser["Username"]
|
||||||
|
UserGroups = CurrentUser.get("Groups", [])
|
||||||
|
DownloadName = EntryPath.name or Root
|
||||||
|
|
||||||
|
if EntryPath.is_dir():
|
||||||
|
TempZip = tempfile.NamedTemporaryFile(suffix=".zip", delete=False)
|
||||||
|
TempZip.close()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(TempZip.name, "w", zipfile.ZIP_DEFLATED) as ZipHandle:
|
||||||
|
_AddDirectoryToZip(ZipHandle, Root, Path, Username, UserGroups, "")
|
||||||
|
|
||||||
|
EncodedName = quote(f"{DownloadName}.zip")
|
||||||
|
Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"}
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
TempZip.name,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers=Headers,
|
||||||
|
background=BackgroundTask(os.remove, TempZip.name),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not EntryPath.is_file():
|
||||||
|
raise HTTPException(status_code=400, detail="Not a file or directory.")
|
||||||
|
|
||||||
|
MimeType = mimetypes.guess_type(str(EntryPath))[0] or "application/octet-stream"
|
||||||
|
EncodedName = quote(DownloadName)
|
||||||
|
Headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{EncodedName}"}
|
||||||
|
|
||||||
|
def IterateDownload():
|
||||||
|
with EntryPath.open("rb") as SourceFile:
|
||||||
|
while Chunk := SourceFile.read(1024 * 1024):
|
||||||
|
yield Chunk
|
||||||
|
|
||||||
|
return StreamingResponse(IterateDownload(), media_type=MimeType, headers=Headers)
|
||||||
|
|
||||||
|
|
||||||
@Application.get("/api/book")
|
@Application.get("/api/book")
|
||||||
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
|
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
|
||||||
RequirePathAccessible(Root, Path, CurrentUser)
|
RequirePathAccessible(Root, Path, CurrentUser)
|
||||||
|
|||||||
@@ -156,6 +156,25 @@ export function GetRawFileUrl(RootName, RelativePath) {
|
|||||||
return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`;
|
return `${ApiBase}/raw/${encodeURIComponent(RootName)}/${EncodedSegments.join("/")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetDownloadUrl(RootName, RelativePath) {
|
||||||
|
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||||
|
return `${ApiBase}/download?${Params}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function UploadFiles(RootName, RelativePath, Files) {
|
||||||
|
const Form = new FormData();
|
||||||
|
for (const File of Files) {
|
||||||
|
Form.append("Files", File);
|
||||||
|
}
|
||||||
|
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||||
|
const Response = await fetch(`${ApiBase}/upload?${Params}`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: Form,
|
||||||
|
});
|
||||||
|
return ParseJsonOrThrow(Response);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveFile(RootName, RelativePath, Content) {
|
export function SaveFile(RootName, RelativePath, Content) {
|
||||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||||
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
|
return FetchJson(`/file?${Params}`, { method: "PUT", body: Content });
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateEntry,
|
CreateEntry,
|
||||||
DeleteEntry,
|
DeleteEntry,
|
||||||
FetchLock,
|
FetchLock,
|
||||||
FetchTree,
|
FetchTree,
|
||||||
|
GetDownloadUrl,
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
RenameEntry,
|
RenameEntry,
|
||||||
SetLock as SetLockApi,
|
SetLock as SetLockApi,
|
||||||
|
UploadFiles,
|
||||||
} from "../Api.js";
|
} from "../Api.js";
|
||||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||||
import AccessModal from "./AccessModal.jsx";
|
import AccessModal from "./AccessModal.jsx";
|
||||||
@@ -31,6 +33,8 @@ export default function DirectoryPane({
|
|||||||
const [LoadError, SetLoadError] = useState(null);
|
const [LoadError, SetLoadError] = useState(null);
|
||||||
const [ModalConfig, SetModalConfig] = useState(null);
|
const [ModalConfig, SetModalConfig] = useState(null);
|
||||||
const [ErrorMessage, SetErrorMessage] = useState(null);
|
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||||
|
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
|
||||||
|
const FileInputRef = useRef(null);
|
||||||
|
|
||||||
async function Reload() {
|
async function Reload() {
|
||||||
try {
|
try {
|
||||||
@@ -154,16 +158,71 @@ export default function DirectoryPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function HandleUpload(Files) {
|
||||||
|
if (!Files || Files.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Result = await UploadFiles(RootName, DirectoryPath, Files);
|
||||||
|
if (Result.Errors && Result.Errors.length > 0) {
|
||||||
|
SetErrorMessage(Result.Errors.join("; "));
|
||||||
|
}
|
||||||
|
await Reload();
|
||||||
|
OnEntryCreated();
|
||||||
|
} catch (Error) {
|
||||||
|
SetErrorMessage(Error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleUploadButtonClick() {
|
||||||
|
FileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleFileInputChange(Event) {
|
||||||
|
HandleUpload(Event.target.files);
|
||||||
|
Event.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDragOver(Event) {
|
||||||
|
if (IsGuest) return;
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDragLeave(Event) {
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDrop(Event) {
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(false);
|
||||||
|
if (IsGuest) return;
|
||||||
|
HandleUpload(Event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="DirectoryPane">
|
<div className="DirectoryPane">
|
||||||
{!IsGuest && (
|
{!IsGuest && (
|
||||||
<div className="PreviewToolbar">
|
<div className="PreviewToolbar">
|
||||||
<button onClick={OpenCreateFileModal}>New File</button>
|
<button onClick={OpenCreateFileModal}>New File</button>
|
||||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||||
|
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||||
|
<input
|
||||||
|
ref={FileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={HandleFileInputChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||||
<div className="DirectoryList">
|
<div
|
||||||
|
className={`DirectoryList${IsDraggingOver ? " DraggingOver" : ""}`}
|
||||||
|
onDragOver={HandleDragOver}
|
||||||
|
onDragLeave={HandleDragLeave}
|
||||||
|
onDrop={HandleDrop}
|
||||||
|
>
|
||||||
{Entries.map((Entry) => (
|
{Entries.map((Entry) => (
|
||||||
<div
|
<div
|
||||||
className="DirectoryEntry"
|
className="DirectoryEntry"
|
||||||
@@ -178,31 +237,41 @@ export default function DirectoryPane({
|
|||||||
: GetFileIcon(UseHyphenBullets)}
|
: GetFileIcon(UseHyphenBullets)}
|
||||||
</span>
|
</span>
|
||||||
<span className="TreeLabel">{Entry.Name}</span>
|
<span className="TreeLabel">{Entry.Name}</span>
|
||||||
{!IsGuest && (
|
<span className="TreeRowActions">
|
||||||
<span className="TreeRowActions">
|
<a
|
||||||
<button
|
className="TreeRowActionButton"
|
||||||
className="TreeRowActionButton"
|
href={GetDownloadUrl(RootName, JoinPath(Entry.Name))}
|
||||||
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
|
onClick={(Event) => Event.stopPropagation()}
|
||||||
title="Access control"
|
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
|
||||||
>
|
>
|
||||||
🔒
|
⭳
|
||||||
</button>
|
</a>
|
||||||
<button
|
{!IsGuest && (
|
||||||
className="TreeRowActionButton"
|
<>
|
||||||
onClick={(Event) => HandleRenameClick(Event, Entry)}
|
<button
|
||||||
title="Rename / move"
|
className="TreeRowActionButton"
|
||||||
>
|
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
|
||||||
✎
|
title="Access control"
|
||||||
</button>
|
>
|
||||||
<button
|
🔒
|
||||||
className="TreeRowActionButton"
|
</button>
|
||||||
onClick={(Event) => HandleDeleteClick(Event, Entry)}
|
<button
|
||||||
title="Delete"
|
className="TreeRowActionButton"
|
||||||
>
|
onClick={(Event) => HandleRenameClick(Event, Entry)}
|
||||||
✕
|
title="Rename / move"
|
||||||
</button>
|
>
|
||||||
</span>
|
✎
|
||||||
)}
|
</button>
|
||||||
|
<button
|
||||||
|
className="TreeRowActionButton"
|
||||||
|
onClick={(Event) => HandleDeleteClick(Event, Entry)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{Entries.length === 0 && !LoadError && (
|
{Entries.length === 0 && !LoadError && (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FetchFile,
|
FetchFile,
|
||||||
FetchResolvedFile,
|
FetchResolvedFile,
|
||||||
GetAvatarUrl,
|
GetAvatarUrl,
|
||||||
|
GetDownloadUrl,
|
||||||
GetFileUrl,
|
GetFileUrl,
|
||||||
GetRawFileUrl,
|
GetRawFileUrl,
|
||||||
SaveFile,
|
SaveFile,
|
||||||
@@ -243,6 +244,11 @@ export default function PreviewPane({
|
|||||||
if (FileType === "image") {
|
if (FileType === "image") {
|
||||||
return (
|
return (
|
||||||
<div className="PreviewMedia">
|
<div className="PreviewMedia">
|
||||||
|
<div className={ToolbarClass}>
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -251,6 +257,11 @@ export default function PreviewPane({
|
|||||||
if (FileType === "video") {
|
if (FileType === "video") {
|
||||||
return (
|
return (
|
||||||
<div className="PreviewMedia">
|
<div className="PreviewMedia">
|
||||||
|
<div className={ToolbarClass}>
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<video controls src={GetFileUrl(RootName, FilePath)} />
|
<video controls src={GetFileUrl(RootName, FilePath)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -261,6 +272,9 @@ export default function PreviewPane({
|
|||||||
<div className="PreviewFrame">
|
<div className="PreviewFrame">
|
||||||
<div className={ToolbarClass}>
|
<div className={ToolbarClass}>
|
||||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -272,6 +286,9 @@ export default function PreviewPane({
|
|||||||
<div className="PreviewMarkdown">
|
<div className="PreviewMarkdown">
|
||||||
<div className={ToolbarClass}>
|
<div className={ToolbarClass}>
|
||||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="MarkdownBody"
|
className="MarkdownBody"
|
||||||
@@ -294,6 +311,9 @@ export default function PreviewPane({
|
|||||||
<div className="PreviewText">
|
<div className="PreviewText">
|
||||||
<div className={ToolbarClass}>
|
<div className={ToolbarClass}>
|
||||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<pre className="TextBody">{ResolvedContent}</pre>
|
<pre className="TextBody">{ResolvedContent}</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,6 +332,9 @@ export default function PreviewPane({
|
|||||||
{IsSaving ? "Saving…" : "Save"}
|
{IsSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
||||||
</div>
|
</div>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreateEntry,
|
CreateEntry,
|
||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
FetchLock,
|
FetchLock,
|
||||||
FetchTree,
|
FetchTree,
|
||||||
GetAvatarUrl,
|
GetAvatarUrl,
|
||||||
|
GetDownloadUrl,
|
||||||
MoveEntry,
|
MoveEntry,
|
||||||
RenameEntry,
|
RenameEntry,
|
||||||
SetLock as SetLockApi,
|
SetLock as SetLockApi,
|
||||||
|
UploadFiles,
|
||||||
} from "../Api.js";
|
} from "../Api.js";
|
||||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||||
import AccessModal from "./AccessModal.jsx";
|
import AccessModal from "./AccessModal.jsx";
|
||||||
@@ -151,19 +153,29 @@ function TreeNode({
|
|||||||
: GetFileIcon(UseHyphenBullets)}
|
: GetFileIcon(UseHyphenBullets)}
|
||||||
</span>
|
</span>
|
||||||
<span className="TreeLabel">{Entry.Name}</span>
|
<span className="TreeLabel">{Entry.Name}</span>
|
||||||
{!IsGuest && (
|
<span className="TreeRowActions">
|
||||||
<span className="TreeRowActions">
|
<a
|
||||||
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
|
className="TreeRowActionButton"
|
||||||
🔒
|
href={GetDownloadUrl(RootName, FullPath)}
|
||||||
</button>
|
onClick={(Event) => Event.stopPropagation()}
|
||||||
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
|
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
|
||||||
✎
|
>
|
||||||
</button>
|
⭳
|
||||||
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
|
</a>
|
||||||
✕
|
{!IsGuest && (
|
||||||
</button>
|
<>
|
||||||
</span>
|
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
|
||||||
)}
|
🔒
|
||||||
|
</button>
|
||||||
|
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{IsRenaming && (
|
{IsRenaming && (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
@@ -284,6 +296,8 @@ export default function Sidebar({
|
|||||||
const [LoadError, SetLoadError] = useState(null);
|
const [LoadError, SetLoadError] = useState(null);
|
||||||
const [ModalConfig, SetModalConfig] = useState(null);
|
const [ModalConfig, SetModalConfig] = useState(null);
|
||||||
const [ErrorMessage, SetErrorMessage] = useState(null);
|
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||||
|
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
|
||||||
|
const FileInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (RootName) {
|
if (RootName) {
|
||||||
@@ -345,6 +359,47 @@ export default function Sidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function HandleUpload(Files) {
|
||||||
|
if (!Files || Files.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Result = await UploadFiles(RootName, CurrentDirectoryPath, Files);
|
||||||
|
if (Result.Errors && Result.Errors.length > 0) {
|
||||||
|
SetErrorMessage(Result.Errors.join("; "));
|
||||||
|
}
|
||||||
|
OnEntryCreated();
|
||||||
|
} catch (Error) {
|
||||||
|
SetErrorMessage(Error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleUploadButtonClick() {
|
||||||
|
FileInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleFileInputChange(Event) {
|
||||||
|
HandleUpload(Event.target.files);
|
||||||
|
Event.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDragOver(Event) {
|
||||||
|
if (IsGuest) return;
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDragLeave(Event) {
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleDrop(Event) {
|
||||||
|
Event.preventDefault();
|
||||||
|
SetIsDraggingOver(false);
|
||||||
|
if (IsGuest) return;
|
||||||
|
HandleUpload(Event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="Sidebar">
|
<div className="Sidebar">
|
||||||
<div className="SidebarHeader">
|
<div className="SidebarHeader">
|
||||||
@@ -364,10 +419,25 @@ export default function Sidebar({
|
|||||||
<div className="SidebarNewEntry SidebarNewEntryTop">
|
<div className="SidebarNewEntry SidebarNewEntryTop">
|
||||||
<button onClick={OpenCreateFileModal}>New File</button>
|
<button onClick={OpenCreateFileModal}>New File</button>
|
||||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||||
|
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!IsGuest && (
|
||||||
|
<input
|
||||||
|
ref={FileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={HandleFileInputChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||||
<div className="SidebarTree">
|
<div
|
||||||
|
className={`SidebarTree${IsDraggingOver ? " DraggingOver" : ""}`}
|
||||||
|
onDragOver={HandleDragOver}
|
||||||
|
onDragLeave={HandleDragLeave}
|
||||||
|
onDrop={HandleDrop}
|
||||||
|
>
|
||||||
{RootEntries.map((Entry) => (
|
{RootEntries.map((Entry) => (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
key={Entry.Name}
|
key={Entry.Name}
|
||||||
@@ -395,6 +465,7 @@ export default function Sidebar({
|
|||||||
<div className="SidebarNewEntry">
|
<div className="SidebarNewEntry">
|
||||||
<button onClick={OpenCreateFileModal}>New File</button>
|
<button onClick={OpenCreateFileModal}>New File</button>
|
||||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||||
|
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,10 @@ img,
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.TreeRowActionButton:hover {
|
.TreeRowActionButton:hover {
|
||||||
@@ -309,6 +313,13 @@ img,
|
|||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.DirectoryList.DraggingOver,
|
||||||
|
.SidebarTree.DraggingOver {
|
||||||
|
outline: 2px dashed var(--Purple);
|
||||||
|
outline-offset: -2px;
|
||||||
|
background: var(--SurfaceAlt);
|
||||||
|
}
|
||||||
|
|
||||||
.DirectoryEntry {
|
.DirectoryEntry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -370,6 +381,21 @@ img,
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.DownloadButton {
|
||||||
|
background: var(--SurfaceAlt);
|
||||||
|
color: var(--Text);
|
||||||
|
border: 1px solid var(--Purple);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.DownloadButton:hover {
|
||||||
|
background: var(--Border);
|
||||||
|
}
|
||||||
|
|
||||||
.SaveErrorText {
|
.SaveErrorText {
|
||||||
color: var(--Red);
|
color: var(--Red);
|
||||||
align-self: center;
|
align-self: center;
|
||||||
|
|||||||
Reference in new issue
Block a user