mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 10:43:17 +02:00
Added modals for popups, and fixed root-level bug.
This commit is contained in:
1 parent
9736a55e00
commit
104198036a
11 files changed
+312
-62
No files matched your search
@@ -47,6 +47,22 @@ def ResolvePath(RootName: str, RelativePath: str) -> Path:
|
|||||||
return Candidate
|
return Candidate
|
||||||
|
|
||||||
|
|
||||||
|
def NormalizeRelativePath(BaseDirectory: str, RawPath: str) -> str:
|
||||||
|
Combined = f"{BaseDirectory}/{RawPath}" if BaseDirectory else RawPath
|
||||||
|
Parts: list[str] = []
|
||||||
|
|
||||||
|
for Part in Combined.split("/"):
|
||||||
|
if Part in ("", "."):
|
||||||
|
continue
|
||||||
|
if Part == "..":
|
||||||
|
if Parts:
|
||||||
|
Parts.pop()
|
||||||
|
continue
|
||||||
|
Parts.append(Part)
|
||||||
|
|
||||||
|
return "/".join(Parts)
|
||||||
|
|
||||||
|
|
||||||
def IsInternalPath(EntryPath: Path) -> bool:
|
def IsInternalPath(EntryPath: Path) -> bool:
|
||||||
SaraTagConfig = Config.get("SaraTag", {})
|
SaraTagConfig = Config.get("SaraTag", {})
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -23,7 +23,7 @@ from Auth import (
|
|||||||
)
|
)
|
||||||
from BookReader import ReadBookForFile
|
from BookReader import ReadBookForFile
|
||||||
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
|
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
|
||||||
from Core import ResolvePath
|
from Core import NormalizeRelativePath, ResolvePath
|
||||||
from Embeds import BuildOgTags
|
from Embeds import BuildOgTags
|
||||||
from Editor import WriteTextFile
|
from Editor import WriteTextFile
|
||||||
from Explorer import (
|
from Explorer import (
|
||||||
@@ -440,10 +440,11 @@ def PatchTreeEndpoint(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
RequirePathAccessible(Root, Path, CurrentUser)
|
RequirePathAccessible(Root, Path, CurrentUser)
|
||||||
|
|
||||||
|
ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else ""
|
||||||
|
|
||||||
if Body.DestinationPath:
|
if Body.DestinationPath:
|
||||||
Destination = Body.DestinationPath
|
Destination = NormalizeRelativePath(ParentPath, Body.DestinationPath)
|
||||||
elif Body.NewName:
|
elif Body.NewName:
|
||||||
ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else ""
|
|
||||||
Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName
|
Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.")
|
raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.")
|
||||||
|
|||||||
+4
-20
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from Core import ResolvePath
|
from Core import NormalizeRelativePath, ResolvePath
|
||||||
from Explorer import ClassifyFile, ListDirectory
|
from Explorer import ClassifyFile, ListDirectory
|
||||||
from Locks import IsPathAccessible
|
from Locks import IsPathAccessible
|
||||||
from Preview import ReadTextFile
|
from Preview import ReadTextFile
|
||||||
@@ -94,22 +94,6 @@ def _ResolveLocalPortals(Content: str) -> str:
|
|||||||
return "".join(Output)
|
return "".join(Output)
|
||||||
|
|
||||||
|
|
||||||
def _NormalizeRelativePath(BaseDirectory: str, RawPath: str) -> str:
|
|
||||||
Combined = f"{BaseDirectory}/{RawPath}" if BaseDirectory else RawPath
|
|
||||||
Parts: list[str] = []
|
|
||||||
|
|
||||||
for Part in Combined.split("/"):
|
|
||||||
if Part in ("", "."):
|
|
||||||
continue
|
|
||||||
if Part == "..":
|
|
||||||
if Parts:
|
|
||||||
Parts.pop()
|
|
||||||
continue
|
|
||||||
Parts.append(Part)
|
|
||||||
|
|
||||||
return "/".join(Parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _ReadAccessiblePortalSource(
|
def _ReadAccessiblePortalSource(
|
||||||
RootName: str, RelativePath: str, Username: str, UserGroups: list[str]
|
RootName: str, RelativePath: str, Username: str, UserGroups: list[str]
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
@@ -171,7 +155,7 @@ def _ResolveCrossFileReference(
|
|||||||
if not PathText:
|
if not PathText:
|
||||||
return _FindNamedPortalInDirectory(RootName, RefererDirectory, Name, Username, UserGroups)
|
return _FindNamedPortalInDirectory(RootName, RefererDirectory, Name, Username, UserGroups)
|
||||||
|
|
||||||
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
|
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
TargetPath = ResolvePath(RootName, TargetRelativePath)
|
TargetPath = ResolvePath(RootName, TargetRelativePath)
|
||||||
@@ -205,7 +189,7 @@ def _ResolveWholeFilePipe(
|
|||||||
Username: str,
|
Username: str,
|
||||||
UserGroups: list[str],
|
UserGroups: list[str],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
|
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
|
||||||
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
|
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
|
||||||
|
|
||||||
if Content is None:
|
if Content is None:
|
||||||
@@ -222,7 +206,7 @@ def _ResolveLineNumberReference(
|
|||||||
Username: str,
|
Username: str,
|
||||||
UserGroups: list[str],
|
UserGroups: list[str],
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
|
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
|
||||||
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
|
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
|
||||||
|
|
||||||
if Content is None:
|
if Content is None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DirectoryPane from "./Components/DirectoryPane.jsx";
|
|||||||
import TagPanel from "./Components/TagPanel.jsx";
|
import TagPanel from "./Components/TagPanel.jsx";
|
||||||
import SettingsPanel from "./Components/SettingsPanel.jsx";
|
import SettingsPanel from "./Components/SettingsPanel.jsx";
|
||||||
import LoginScreen from "./Components/LoginScreen.jsx";
|
import LoginScreen from "./Components/LoginScreen.jsx";
|
||||||
|
import ErrorToast from "./Components/ErrorToast.jsx";
|
||||||
import {
|
import {
|
||||||
EnsureDirectoryNote,
|
EnsureDirectoryNote,
|
||||||
FetchAuthStatus,
|
FetchAuthStatus,
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
const [SidebarKey, SetSidebarKey] = useState(0);
|
const [SidebarKey, SetSidebarKey] = useState(0);
|
||||||
const [Settings, SetSettings] = useState(null);
|
const [Settings, SetSettings] = useState(null);
|
||||||
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
|
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
|
||||||
|
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
FetchAuthStatus().then((Status) => {
|
FetchAuthStatus().then((Status) => {
|
||||||
@@ -237,7 +239,7 @@ export default function App() {
|
|||||||
const Created = await EnsureDirectoryNote(ActiveRoot, DirectoryPath);
|
const Created = await EnsureDirectoryNote(ActiveRoot, DirectoryPath);
|
||||||
HandleSelectDirectoryNote(DirectoryPath, Created.Name, Created.FileType);
|
HandleSelectDirectoryNote(DirectoryPath, Created.Name, Created.FileType);
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +393,7 @@ export default function App() {
|
|||||||
OnProfileUpdated={HandleProfileUpdated}
|
OnProfileUpdated={HandleProfileUpdated}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function ConfirmModal({ Title, Message, ConfirmLabel, OnConfirm, OnCancel }) {
|
||||||
|
function HandleOverlayClick(Event) {
|
||||||
|
Event.stopPropagation();
|
||||||
|
OnCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="SettingsOverlay" onClick={HandleOverlayClick}>
|
||||||
|
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||||
|
<div className="SettingsHeader">
|
||||||
|
<span>{Title || "Confirm"}</span>
|
||||||
|
<button className="SettingsClose" onClick={OnCancel}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="SettingsBody">
|
||||||
|
<div className="SettingsRow">{Message}</div>
|
||||||
|
</div>
|
||||||
|
<div className="SettingsFooter">
|
||||||
|
<button onClick={OnCancel}>Cancel</button>
|
||||||
|
<button onClick={OnConfirm}>{ConfirmLabel || "Confirm"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,9 @@ import {
|
|||||||
} 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";
|
||||||
|
import PromptModal from "./PromptModal.jsx";
|
||||||
|
import ConfirmModal from "./ConfirmModal.jsx";
|
||||||
|
import ErrorToast from "./ErrorToast.jsx";
|
||||||
|
|
||||||
export default function DirectoryPane({
|
export default function DirectoryPane({
|
||||||
RootName,
|
RootName,
|
||||||
@@ -27,6 +30,7 @@ export default function DirectoryPane({
|
|||||||
const [Entries, SetEntries] = useState([]);
|
const [Entries, SetEntries] = useState([]);
|
||||||
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);
|
||||||
|
|
||||||
async function Reload() {
|
async function Reload() {
|
||||||
try {
|
try {
|
||||||
@@ -71,15 +75,21 @@ export default function DirectoryPane({
|
|||||||
InitialGroups: (Existing && Existing.Groups) || [],
|
InitialGroups: (Existing && Existing.Groups) || [],
|
||||||
});
|
});
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleRename(Event, Entry) {
|
function HandleRenameClick(Event, Entry) {
|
||||||
Event.stopPropagation();
|
Event.stopPropagation();
|
||||||
|
SetModalConfig({ Mode: "rename", Entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HandleRenameConfirm(NewValue) {
|
||||||
|
const Entry = ModalConfig.Entry;
|
||||||
|
SetModalConfig(null);
|
||||||
|
if (NewValue === Entry.Name) return;
|
||||||
|
|
||||||
const FullPath = JoinPath(Entry.Name);
|
const FullPath = JoinPath(Entry.Name);
|
||||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
|
||||||
if (!NewValue || NewValue === Entry.Name) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (NewValue.includes("/")) {
|
if (NewValue.includes("/")) {
|
||||||
@@ -91,24 +101,30 @@ export default function DirectoryPane({
|
|||||||
}
|
}
|
||||||
await Reload();
|
await Reload();
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleDelete(Event, Entry) {
|
function HandleDeleteClick(Event, Entry) {
|
||||||
Event.stopPropagation();
|
Event.stopPropagation();
|
||||||
const FullPath = JoinPath(Entry.Name);
|
|
||||||
|
|
||||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
if (ConfirmBeforeDelete) {
|
||||||
return;
|
SetModalConfig({ Mode: "confirmDelete", Entry });
|
||||||
|
} else {
|
||||||
|
HandleDeleteConfirm(Entry);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HandleDeleteConfirm(Entry) {
|
||||||
|
SetModalConfig(null);
|
||||||
|
const FullPath = JoinPath(Entry.Name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DeleteEntry(RootName, FullPath);
|
await DeleteEntry(RootName, FullPath);
|
||||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||||
await Reload();
|
await Reload();
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +150,7 @@ export default function DirectoryPane({
|
|||||||
|
|
||||||
SetModalConfig(null);
|
SetModalConfig(null);
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +189,14 @@ export default function DirectoryPane({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="TreeRowActionButton"
|
className="TreeRowActionButton"
|
||||||
onClick={(Event) => HandleRename(Event, Entry)}
|
onClick={(Event) => HandleRenameClick(Event, Entry)}
|
||||||
title="Rename / move"
|
title="Rename / move"
|
||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="TreeRowActionButton"
|
className="TreeRowActionButton"
|
||||||
onClick={(Event) => HandleDelete(Event, Entry)}
|
onClick={(Event) => HandleDeleteClick(Event, Entry)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
@@ -229,6 +245,26 @@ export default function DirectoryPane({
|
|||||||
OnCancel={() => SetModalConfig(null)}
|
OnCancel={() => SetModalConfig(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{ModalConfig && ModalConfig.Mode === "rename" && (
|
||||||
|
<PromptModal
|
||||||
|
Title={`Rename "${ModalConfig.Entry.Name}"`}
|
||||||
|
Label="New name (or a relative path like ../Name to move):"
|
||||||
|
InitialValue={ModalConfig.Entry.Name}
|
||||||
|
ConfirmLabel="Rename"
|
||||||
|
OnConfirm={HandleRenameConfirm}
|
||||||
|
OnCancel={() => SetModalConfig(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ModalConfig && ModalConfig.Mode === "confirmDelete" && (
|
||||||
|
<ConfirmModal
|
||||||
|
Title="Delete"
|
||||||
|
Message={`Delete "${ModalConfig.Entry.Name}"? This can't be undone.`}
|
||||||
|
ConfirmLabel="Delete"
|
||||||
|
OnConfirm={() => HandleDeleteConfirm(ModalConfig.Entry)}
|
||||||
|
OnCancel={() => SetModalConfig(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function ErrorToast({ Message, OnDismiss }) {
|
||||||
|
if (!Message) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ErrorToast">
|
||||||
|
<span>{Message}</span>
|
||||||
|
<button className="ErrorToastClose" onClick={OnDismiss}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
export default function PromptModal({ Title, Label, InitialValue, ConfirmLabel, OnConfirm, OnCancel }) {
|
||||||
|
const [Value, SetValue] = useState(InitialValue || "");
|
||||||
|
|
||||||
|
function HandleConfirm() {
|
||||||
|
if (!Value.trim()) return;
|
||||||
|
OnConfirm(Value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleKeyDown(Event) {
|
||||||
|
if (Event.key === "Enter") {
|
||||||
|
HandleConfirm();
|
||||||
|
} else if (Event.key === "Escape") {
|
||||||
|
OnCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandleOverlayClick(Event) {
|
||||||
|
Event.stopPropagation();
|
||||||
|
OnCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="SettingsOverlay" onClick={HandleOverlayClick}>
|
||||||
|
<div className="SettingsModal" onClick={(Event) => Event.stopPropagation()}>
|
||||||
|
<div className="SettingsHeader">
|
||||||
|
<span>{Title}</span>
|
||||||
|
<button className="SettingsClose" onClick={OnCancel}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="SettingsBody">
|
||||||
|
<div className="SettingsRow">
|
||||||
|
{Label && <label className="SettingsLabel">{Label}</label>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={Value}
|
||||||
|
onChange={(Event) => SetValue(Event.target.value)}
|
||||||
|
onKeyDown={HandleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="SettingsFooter">
|
||||||
|
<button onClick={OnCancel}>Cancel</button>
|
||||||
|
<button onClick={HandleConfirm}>{ConfirmLabel || "OK"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
UploadAvatar,
|
UploadAvatar,
|
||||||
} from "../Api.js";
|
} from "../Api.js";
|
||||||
import Switch from "./Switch.jsx";
|
import Switch from "./Switch.jsx";
|
||||||
|
import PromptModal from "./PromptModal.jsx";
|
||||||
|
import ConfirmModal from "./ConfirmModal.jsx";
|
||||||
import { PaletteRoles } from "../ThemeRoles.js";
|
import { PaletteRoles } from "../ThemeRoles.js";
|
||||||
|
|
||||||
export default function SettingsPanel({
|
export default function SettingsPanel({
|
||||||
@@ -42,6 +44,8 @@ export default function SettingsPanel({
|
|||||||
const [NewThemeName, SetNewThemeName] = useState("");
|
const [NewThemeName, SetNewThemeName] = useState("");
|
||||||
const [NewThemePalette, SetNewThemePalette] = useState(null);
|
const [NewThemePalette, SetNewThemePalette] = useState(null);
|
||||||
const [ThemeError, SetThemeError] = useState(null);
|
const [ThemeError, SetThemeError] = useState(null);
|
||||||
|
const [IsRenamingTheme, SetIsRenamingTheme] = useState(false);
|
||||||
|
const [IsConfirmingThemeDelete, SetIsConfirmingThemeDelete] = useState(false);
|
||||||
|
|
||||||
const [Users, SetUsers] = useState(null);
|
const [Users, SetUsers] = useState(null);
|
||||||
const [NewUsername, SetNewUsername] = useState("");
|
const [NewUsername, SetNewUsername] = useState("");
|
||||||
@@ -68,6 +72,7 @@ export default function SettingsPanel({
|
|||||||
const [NewGroupName, SetNewGroupName] = useState("");
|
const [NewGroupName, SetNewGroupName] = useState("");
|
||||||
const [GroupError, SetGroupError] = useState(null);
|
const [GroupError, SetGroupError] = useState(null);
|
||||||
const [ExpandedGroup, SetExpandedGroup] = useState(null);
|
const [ExpandedGroup, SetExpandedGroup] = useState(null);
|
||||||
|
const [ConfirmingDeleteGroupName, SetConfirmingDeleteGroupName] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
FetchSettings().then(SetSettings);
|
FetchSettings().then(SetSettings);
|
||||||
@@ -127,31 +132,38 @@ export default function SettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleRenameTheme() {
|
function HandleRenameThemeClick() {
|
||||||
const NewName = window.prompt("Rename theme to:", Settings.ThemeName);
|
SetIsRenamingTheme(true);
|
||||||
if (!NewName || !NewName.trim() || NewName.trim() === Settings.ThemeName) return;
|
}
|
||||||
|
|
||||||
|
async function HandleRenameThemeConfirm(NewName) {
|
||||||
|
SetIsRenamingTheme(false);
|
||||||
|
if (NewName === Settings.ThemeName) return;
|
||||||
|
|
||||||
SetThemeError(null);
|
SetThemeError(null);
|
||||||
try {
|
try {
|
||||||
const Palette = await FetchTheme(Settings.ThemeName);
|
const Palette = await FetchTheme(Settings.ThemeName);
|
||||||
await SaveTheme(NewName.trim(), Palette);
|
await SaveTheme(NewName, Palette);
|
||||||
await DeleteTheme(Settings.ThemeName);
|
await DeleteTheme(Settings.ThemeName);
|
||||||
const Updated = await FetchThemeNames();
|
const Updated = await FetchThemeNames();
|
||||||
SetThemeNames(Updated);
|
SetThemeNames(Updated);
|
||||||
UpdateField("ThemeName", NewName.trim());
|
UpdateField("ThemeName", NewName);
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
SetThemeError(Error.message);
|
SetThemeError(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleDeleteTheme() {
|
function HandleDeleteThemeClick() {
|
||||||
if (ThemeNames.length <= 1) {
|
if (ThemeNames.length <= 1) {
|
||||||
SetThemeError("Can't delete the only remaining theme.");
|
SetThemeError("Can't delete the only remaining theme.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!window.confirm(`Delete theme "${Settings.ThemeName}"? This can't be undone.`)) return;
|
SetIsConfirmingThemeDelete(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HandleDeleteThemeConfirm() {
|
||||||
|
SetIsConfirmingThemeDelete(false);
|
||||||
SetThemeError(null);
|
SetThemeError(null);
|
||||||
try {
|
try {
|
||||||
await DeleteTheme(Settings.ThemeName);
|
await DeleteTheme(Settings.ThemeName);
|
||||||
@@ -260,8 +272,13 @@ export default function SettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleDeleteGroup(Name) {
|
function HandleDeleteGroupClick(Name) {
|
||||||
if (!window.confirm(`Delete group "${Name}"? This can't be undone.`)) return;
|
SetConfirmingDeleteGroupName(Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HandleDeleteGroupConfirm() {
|
||||||
|
const Name = ConfirmingDeleteGroupName;
|
||||||
|
SetConfirmingDeleteGroupName(null);
|
||||||
|
|
||||||
SetGroupError(null);
|
SetGroupError(null);
|
||||||
try {
|
try {
|
||||||
@@ -392,8 +409,8 @@ export default function SettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="SettingsRowInline">
|
<div className="SettingsRowInline">
|
||||||
<button onClick={HandleRenameTheme}>Rename</button>
|
<button onClick={HandleRenameThemeClick}>Rename</button>
|
||||||
<button onClick={HandleDeleteTheme}>Delete</button>
|
<button onClick={HandleDeleteThemeClick}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -610,8 +627,8 @@ export default function SettingsPanel({
|
|||||||
>
|
>
|
||||||
{ExpandedGroup === Group.Name ? "Hide" : "Members"}
|
{ExpandedGroup === Group.Name ? "Hide" : "Members"}
|
||||||
</button>
|
</button>
|
||||||
{Group.Name !== "Admin" && (
|
{Group.Name !== "Admin" && Group.Name !== "Guest" && (
|
||||||
<button onClick={() => HandleDeleteGroup(Group.Name)}>Delete</button>
|
<button onClick={() => HandleDeleteGroupClick(Group.Name)}>Delete</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -658,6 +675,35 @@ export default function SettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{IsRenamingTheme && (
|
||||||
|
<PromptModal
|
||||||
|
Title="Rename theme"
|
||||||
|
Label="New name:"
|
||||||
|
InitialValue={Settings.ThemeName}
|
||||||
|
ConfirmLabel="Rename"
|
||||||
|
OnConfirm={HandleRenameThemeConfirm}
|
||||||
|
OnCancel={() => SetIsRenamingTheme(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{IsConfirmingThemeDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
Title="Delete theme"
|
||||||
|
Message={`Delete theme "${Settings.ThemeName}"? This can't be undone.`}
|
||||||
|
ConfirmLabel="Delete"
|
||||||
|
OnConfirm={HandleDeleteThemeConfirm}
|
||||||
|
OnCancel={() => SetIsConfirmingThemeDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{ConfirmingDeleteGroupName && (
|
||||||
|
<ConfirmModal
|
||||||
|
Title="Delete group"
|
||||||
|
Message={`Delete group "${ConfirmingDeleteGroupName}"? This can't be undone.`}
|
||||||
|
ConfirmLabel="Delete"
|
||||||
|
OnConfirm={HandleDeleteGroupConfirm}
|
||||||
|
OnCancel={() => SetConfirmingDeleteGroupName(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
} 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";
|
||||||
|
import PromptModal from "./PromptModal.jsx";
|
||||||
|
import ConfirmModal from "./ConfirmModal.jsx";
|
||||||
|
import ErrorToast from "./ErrorToast.jsx";
|
||||||
|
|
||||||
function TreeNode({
|
function TreeNode({
|
||||||
RootName,
|
RootName,
|
||||||
@@ -34,6 +37,9 @@ function TreeNode({
|
|||||||
}) {
|
}) {
|
||||||
const [IsExpanded, SetIsExpanded] = useState(false);
|
const [IsExpanded, SetIsExpanded] = useState(false);
|
||||||
const [Children, SetChildren] = useState(null);
|
const [Children, SetChildren] = useState(null);
|
||||||
|
const [IsRenaming, SetIsRenaming] = useState(false);
|
||||||
|
const [IsConfirmingDelete, SetIsConfirmingDelete] = useState(false);
|
||||||
|
const [ErrorMessage, SetErrorMessage] = useState(null);
|
||||||
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
|
const FullPath = ParentPath ? `${ParentPath}/${Entry.Name}` : Entry.Name;
|
||||||
const IsSelected = SelectedFile === FullPath;
|
const IsSelected = SelectedFile === FullPath;
|
||||||
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
|
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
|
||||||
@@ -82,10 +88,14 @@ function TreeNode({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [CurrentDirectoryPath]);
|
}, [CurrentDirectoryPath]);
|
||||||
|
|
||||||
async function HandleRename(Event) {
|
function HandleRenameClick(Event) {
|
||||||
Event.stopPropagation();
|
Event.stopPropagation();
|
||||||
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
|
SetIsRenaming(true);
|
||||||
if (!NewValue || NewValue === Entry.Name) return;
|
}
|
||||||
|
|
||||||
|
async function HandleRenameConfirm(NewValue) {
|
||||||
|
SetIsRenaming(false);
|
||||||
|
if (NewValue === Entry.Name) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (NewValue.includes("/")) {
|
if (NewValue.includes("/")) {
|
||||||
@@ -97,22 +107,28 @@ function TreeNode({
|
|||||||
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
|
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
|
||||||
}
|
}
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function HandleDelete(Event) {
|
function HandleDeleteClick(Event) {
|
||||||
Event.stopPropagation();
|
Event.stopPropagation();
|
||||||
|
|
||||||
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
|
if (ConfirmBeforeDelete) {
|
||||||
return;
|
SetIsConfirmingDelete(true);
|
||||||
|
} else {
|
||||||
|
HandleDeleteConfirm();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function HandleDeleteConfirm() {
|
||||||
|
SetIsConfirmingDelete(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await DeleteEntry(RootName, FullPath);
|
await DeleteEntry(RootName, FullPath);
|
||||||
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
OnEntryDeleted(FullPath, Entry.IsDirectory);
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,15 +156,35 @@ function TreeNode({
|
|||||||
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
|
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
|
||||||
🔒
|
🔒
|
||||||
</button>
|
</button>
|
||||||
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
|
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
<button className="TreeRowActionButton" onClick={HandleDelete} title="Delete">
|
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{IsRenaming && (
|
||||||
|
<PromptModal
|
||||||
|
Title={`Rename "${Entry.Name}"`}
|
||||||
|
Label="New name (or a relative path like ../Name to move):"
|
||||||
|
InitialValue={Entry.Name}
|
||||||
|
ConfirmLabel="Rename"
|
||||||
|
OnConfirm={HandleRenameConfirm}
|
||||||
|
OnCancel={() => SetIsRenaming(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{IsConfirmingDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
Title="Delete"
|
||||||
|
Message={`Delete "${Entry.Name}"? This can't be undone.`}
|
||||||
|
ConfirmLabel="Delete"
|
||||||
|
OnConfirm={HandleDeleteConfirm}
|
||||||
|
OnCancel={() => SetIsConfirmingDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||||
{IsExpanded && Children && (
|
{IsExpanded && Children && (
|
||||||
<div className="TreeChildren">
|
<div className="TreeChildren">
|
||||||
{Children.map((Child) => (
|
{Children.map((Child) => (
|
||||||
@@ -247,6 +283,7 @@ export default function Sidebar({
|
|||||||
const [RootEntries, SetRootEntries] = useState([]);
|
const [RootEntries, SetRootEntries] = useState([]);
|
||||||
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);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (RootName) {
|
if (RootName) {
|
||||||
@@ -280,7 +317,7 @@ export default function Sidebar({
|
|||||||
InitialGroups: (Existing && Existing.Groups) || [],
|
InitialGroups: (Existing && Existing.Groups) || [],
|
||||||
});
|
});
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +341,7 @@ export default function Sidebar({
|
|||||||
|
|
||||||
SetModalConfig(null);
|
SetModalConfig(null);
|
||||||
} catch (Error) {
|
} catch (Error) {
|
||||||
window.alert(Error.message);
|
SetErrorMessage(Error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +433,7 @@ export default function Sidebar({
|
|||||||
OnCancel={() => SetModalConfig(null)}
|
OnCancel={() => SetModalConfig(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -784,6 +784,38 @@ img,
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ErrorToast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--Surface);
|
||||||
|
border: 1px solid var(--Red);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--Text);
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ErrorToastClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--TextMuted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ErrorToastClose:hover {
|
||||||
|
color: var(--Text);
|
||||||
|
}
|
||||||
|
|
||||||
.SettingsHint {
|
.SettingsHint {
|
||||||
color: var(--TextMuted);
|
color: var(--TextMuted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
Reference in new issue
Block a user