mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +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)
|
||||
|
||||
|
||||
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:
|
||||
EntryPath = ResolvePath(RootName, RelativePath)
|
||||
|
||||
|
||||
+111
@@ -2,8 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
ApplicationDirectory = Path(__file__).resolve().parent
|
||||
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.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, StreamingResponse
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
from Auth import (
|
||||
CreateSession,
|
||||
@@ -34,6 +39,7 @@ from Explorer import (
|
||||
ListDirectory,
|
||||
ListRoots,
|
||||
MoveEntry,
|
||||
SaveUploadedFile,
|
||||
)
|
||||
from Groups import (
|
||||
CreateGroup,
|
||||
@@ -429,6 +435,36 @@ def PostTreeEndpoint(
|
||||
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):
|
||||
NewName: str | None = None
|
||||
DestinationPath: str | None = None
|
||||
@@ -622,6 +658,81 @@ def GetRawFileEndpoint(
|
||||
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")
|
||||
def GetBookEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> dict:
|
||||
RequirePathAccessible(Root, Path, CurrentUser)
|
||||
|
||||
@@ -156,6 +156,25 @@ export function GetRawFileUrl(RootName, RelativePath) {
|
||||
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) {
|
||||
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
|
||||
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 {
|
||||
CreateEntry,
|
||||
DeleteEntry,
|
||||
FetchLock,
|
||||
FetchTree,
|
||||
GetDownloadUrl,
|
||||
MoveEntry,
|
||||
RenameEntry,
|
||||
SetLock as SetLockApi,
|
||||
UploadFiles,
|
||||
} from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
import AccessModal from "./AccessModal.jsx";
|
||||
@@ -31,6 +33,8 @@ export default function DirectoryPane({
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
const [ModalConfig, SetModalConfig] = useState(null);
|
||||
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
|
||||
const FileInputRef = useRef(null);
|
||||
|
||||
async function Reload() {
|
||||
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 (
|
||||
<div className="DirectoryPane">
|
||||
{!IsGuest && (
|
||||
<div className="PreviewToolbar">
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||
<input
|
||||
ref={FileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={HandleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="DirectoryList">
|
||||
<div
|
||||
className={`DirectoryList${IsDraggingOver ? " DraggingOver" : ""}`}
|
||||
onDragOver={HandleDragOver}
|
||||
onDragLeave={HandleDragLeave}
|
||||
onDrop={HandleDrop}
|
||||
>
|
||||
{Entries.map((Entry) => (
|
||||
<div
|
||||
className="DirectoryEntry"
|
||||
@@ -178,31 +237,41 @@ export default function DirectoryPane({
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
{!IsGuest && (
|
||||
<span className="TreeRowActions">
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
|
||||
title="Access control"
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRenameClick(Event, Entry)}
|
||||
title="Rename / move"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleDeleteClick(Event, Entry)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<span className="TreeRowActions">
|
||||
<a
|
||||
className="TreeRowActionButton"
|
||||
href={GetDownloadUrl(RootName, JoinPath(Entry.Name))}
|
||||
onClick={(Event) => Event.stopPropagation()}
|
||||
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
|
||||
>
|
||||
⭳
|
||||
</a>
|
||||
{!IsGuest && (
|
||||
<>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRequestLockEdit(Event, Entry)}
|
||||
title="Access control"
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleRenameClick(Event, Entry)}
|
||||
title="Rename / move"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
className="TreeRowActionButton"
|
||||
onClick={(Event) => HandleDeleteClick(Event, Entry)}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{Entries.length === 0 && !LoadError && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
FetchFile,
|
||||
FetchResolvedFile,
|
||||
GetAvatarUrl,
|
||||
GetDownloadUrl,
|
||||
GetFileUrl,
|
||||
GetRawFileUrl,
|
||||
SaveFile,
|
||||
@@ -243,6 +244,11 @@ export default function PreviewPane({
|
||||
if (FileType === "image") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<div className={ToolbarClass}>
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<img src={GetFileUrl(RootName, FilePath)} alt={FilePath} />
|
||||
</div>
|
||||
);
|
||||
@@ -251,6 +257,11 @@ export default function PreviewPane({
|
||||
if (FileType === "video") {
|
||||
return (
|
||||
<div className="PreviewMedia">
|
||||
<div className={ToolbarClass}>
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<video controls src={GetFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
);
|
||||
@@ -261,6 +272,9 @@ export default function PreviewPane({
|
||||
<div className="PreviewFrame">
|
||||
<div className={ToolbarClass}>
|
||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<iframe title={FilePath} sandbox="" src={GetRawFileUrl(RootName, FilePath)} />
|
||||
</div>
|
||||
@@ -272,6 +286,9 @@ export default function PreviewPane({
|
||||
<div className="PreviewMarkdown">
|
||||
<div className={ToolbarClass}>
|
||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
className="MarkdownBody"
|
||||
@@ -294,6 +311,9 @@ export default function PreviewPane({
|
||||
<div className="PreviewText">
|
||||
<div className={ToolbarClass}>
|
||||
{!IsGuest && <button onClick={() => SetIsEditing(true)}>Edit</button>}
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<pre className="TextBody">{ResolvedContent}</pre>
|
||||
</div>
|
||||
@@ -312,6 +332,9 @@ export default function PreviewPane({
|
||||
{IsSaving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
)}
|
||||
<a className="DownloadButton" href={GetDownloadUrl(RootName, FilePath)}>
|
||||
Download
|
||||
</a>
|
||||
{SaveError && <span className="SaveErrorText">{SaveError}</span>}
|
||||
</div>
|
||||
<CodeMirror
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
CreateEntry,
|
||||
@@ -6,9 +6,11 @@ import {
|
||||
FetchLock,
|
||||
FetchTree,
|
||||
GetAvatarUrl,
|
||||
GetDownloadUrl,
|
||||
MoveEntry,
|
||||
RenameEntry,
|
||||
SetLock as SetLockApi,
|
||||
UploadFiles,
|
||||
} from "../Api.js";
|
||||
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
|
||||
import AccessModal from "./AccessModal.jsx";
|
||||
@@ -151,19 +153,29 @@ function TreeNode({
|
||||
: GetFileIcon(UseHyphenBullets)}
|
||||
</span>
|
||||
<span className="TreeLabel">{Entry.Name}</span>
|
||||
{!IsGuest && (
|
||||
<span className="TreeRowActions">
|
||||
<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>
|
||||
)}
|
||||
<span className="TreeRowActions">
|
||||
<a
|
||||
className="TreeRowActionButton"
|
||||
href={GetDownloadUrl(RootName, FullPath)}
|
||||
onClick={(Event) => Event.stopPropagation()}
|
||||
title={Entry.IsDirectory ? "Download as .zip" : "Download"}
|
||||
>
|
||||
⭳
|
||||
</a>
|
||||
{!IsGuest && (
|
||||
<>
|
||||
<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>
|
||||
{IsRenaming && (
|
||||
<PromptModal
|
||||
@@ -284,6 +296,8 @@ export default function Sidebar({
|
||||
const [LoadError, SetLoadError] = useState(null);
|
||||
const [ModalConfig, SetModalConfig] = useState(null);
|
||||
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||
const [IsDraggingOver, SetIsDraggingOver] = useState(false);
|
||||
const FileInputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<div className="Sidebar">
|
||||
<div className="SidebarHeader">
|
||||
@@ -364,10 +419,25 @@ export default function Sidebar({
|
||||
<div className="SidebarNewEntry SidebarNewEntryTop">
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||
</div>
|
||||
)}
|
||||
{!IsGuest && (
|
||||
<input
|
||||
ref={FileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={HandleFileInputChange}
|
||||
/>
|
||||
)}
|
||||
{LoadError && <div className="SidebarError">{LoadError}</div>}
|
||||
<div className="SidebarTree">
|
||||
<div
|
||||
className={`SidebarTree${IsDraggingOver ? " DraggingOver" : ""}`}
|
||||
onDragOver={HandleDragOver}
|
||||
onDragLeave={HandleDragLeave}
|
||||
onDrop={HandleDrop}
|
||||
>
|
||||
{RootEntries.map((Entry) => (
|
||||
<TreeNode
|
||||
key={Entry.Name}
|
||||
@@ -395,6 +465,7 @@ export default function Sidebar({
|
||||
<div className="SidebarNewEntry">
|
||||
<button onClick={OpenCreateFileModal}>New File</button>
|
||||
<button onClick={OpenCreateDirectoryModal}>New Directory</button>
|
||||
<button onClick={HandleUploadButtonClick}>Upload</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -227,6 +227,10 @@ img,
|
||||
line-height: 1;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.TreeRowActionButton:hover {
|
||||
@@ -309,6 +313,13 @@ img,
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.DirectoryList.DraggingOver,
|
||||
.SidebarTree.DraggingOver {
|
||||
outline: 2px dashed var(--Purple);
|
||||
outline-offset: -2px;
|
||||
background: var(--SurfaceAlt);
|
||||
}
|
||||
|
||||
.DirectoryEntry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -370,6 +381,21 @@ img,
|
||||
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 {
|
||||
color: var(--Red);
|
||||
align-self: center;
|
||||
|
||||
Reference in new issue
Block a user