Added modals for popups, and fixed root-level bug.

This commit is contained in:
sarah committed 2026-09-21 21:38:12 +02:00
1 parent 9736a55e00
commit 104198036a
11 files changed
+312 -62

No files matched your search

+16
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+4 -1
View File
@@ -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>
); );
} }
+28
View File
@@ -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>
);
}
+49 -13
View File
@@ -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>
); );
} }
+14
View File
@@ -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>
);
}
+52
View File
@@ -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>
);
}
+59 -13
View File
@@ -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>
); );
} }
+50 -12
View File
@@ -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>
); );
} }
+32
View File
@@ -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;