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
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:
SaraTagConfig = Config.get("SaraTag", {})
+4 -3
View File
@@ -23,7 +23,7 @@ from Auth import (
)
from BookReader import ReadBookForFile
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
from Core import ResolvePath
from Core import NormalizeRelativePath, ResolvePath
from Embeds import BuildOgTags
from Editor import WriteTextFile
from Explorer import (
@@ -440,10 +440,11 @@ def PatchTreeEndpoint(
) -> dict:
RequirePathAccessible(Root, Path, CurrentUser)
ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else ""
if Body.DestinationPath:
Destination = Body.DestinationPath
Destination = NormalizeRelativePath(ParentPath, Body.DestinationPath)
elif Body.NewName:
ParentPath = Path.rsplit("/", 1)[0] if "/" in Path else ""
Destination = f"{ParentPath}/{Body.NewName}" if ParentPath else Body.NewName
else:
raise HTTPException(status_code=400, detail="Provide NewName or DestinationPath.")
+4 -20
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from Core import ResolvePath
from Core import NormalizeRelativePath, ResolvePath
from Explorer import ClassifyFile, ListDirectory
from Locks import IsPathAccessible
from Preview import ReadTextFile
@@ -94,22 +94,6 @@ def _ResolveLocalPortals(Content: str) -> str:
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(
RootName: str, RelativePath: str, Username: str, UserGroups: list[str]
) -> str | None:
@@ -171,7 +155,7 @@ def _ResolveCrossFileReference(
if not PathText:
return _FindNamedPortalInDirectory(RootName, RefererDirectory, Name, Username, UserGroups)
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
try:
TargetPath = ResolvePath(RootName, TargetRelativePath)
@@ -205,7 +189,7 @@ def _ResolveWholeFilePipe(
Username: str,
UserGroups: list[str],
) -> str | None:
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
if Content is None:
@@ -222,7 +206,7 @@ def _ResolveLineNumberReference(
Username: str,
UserGroups: list[str],
) -> str | None:
TargetRelativePath = _NormalizeRelativePath(RefererDirectory, PathText)
TargetRelativePath = NormalizeRelativePath(RefererDirectory, PathText)
Content = _ReadAccessiblePortalSource(RootName, TargetRelativePath, Username, UserGroups)
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 SettingsPanel from "./Components/SettingsPanel.jsx";
import LoginScreen from "./Components/LoginScreen.jsx";
import ErrorToast from "./Components/ErrorToast.jsx";
import {
EnsureDirectoryNote,
FetchAuthStatus,
@@ -69,6 +70,7 @@ export default function App() {
const [SidebarKey, SetSidebarKey] = useState(0);
const [Settings, SetSettings] = useState(null);
const [IsSettingsOpen, SetIsSettingsOpen] = useState(false);
const [ErrorMessage, SetErrorMessage] = useState(null);
useEffect(() => {
FetchAuthStatus().then((Status) => {
@@ -237,7 +239,7 @@ export default function App() {
const Created = await EnsureDirectoryNote(ActiveRoot, DirectoryPath);
HandleSelectDirectoryNote(DirectoryPath, Created.Name, Created.FileType);
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -391,6 +393,7 @@ export default function App() {
OnProfileUpdated={HandleProfileUpdated}
/>
)}
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
</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";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx";
import PromptModal from "./PromptModal.jsx";
import ConfirmModal from "./ConfirmModal.jsx";
import ErrorToast from "./ErrorToast.jsx";
export default function DirectoryPane({
RootName,
@@ -27,6 +30,7 @@ export default function DirectoryPane({
const [Entries, SetEntries] = useState([]);
const [LoadError, SetLoadError] = useState(null);
const [ModalConfig, SetModalConfig] = useState(null);
const [ErrorMessage, SetErrorMessage] = useState(null);
async function Reload() {
try {
@@ -71,15 +75,21 @@ export default function DirectoryPane({
InitialGroups: (Existing && Existing.Groups) || [],
});
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
async function HandleRename(Event, Entry) {
function HandleRenameClick(Event, Entry) {
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 NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
if (!NewValue || NewValue === Entry.Name) return;
try {
if (NewValue.includes("/")) {
@@ -91,24 +101,30 @@ export default function DirectoryPane({
}
await Reload();
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
async function HandleDelete(Event, Entry) {
function HandleDeleteClick(Event, Entry) {
Event.stopPropagation();
const FullPath = JoinPath(Entry.Name);
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
return;
if (ConfirmBeforeDelete) {
SetModalConfig({ Mode: "confirmDelete", Entry });
} else {
HandleDeleteConfirm(Entry);
}
}
async function HandleDeleteConfirm(Entry) {
SetModalConfig(null);
const FullPath = JoinPath(Entry.Name);
try {
await DeleteEntry(RootName, FullPath);
OnEntryDeleted(FullPath, Entry.IsDirectory);
await Reload();
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -134,7 +150,7 @@ export default function DirectoryPane({
SetModalConfig(null);
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -173,14 +189,14 @@ export default function DirectoryPane({
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleRename(Event, Entry)}
onClick={(Event) => HandleRenameClick(Event, Entry)}
title="Rename / move"
>
✎
</button>
<button
className="TreeRowActionButton"
onClick={(Event) => HandleDelete(Event, Entry)}
onClick={(Event) => HandleDeleteClick(Event, Entry)}
title="Delete"
>
✕
@@ -229,6 +245,26 @@ export default function DirectoryPane({
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>
);
}
+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,
} from "../Api.js";
import Switch from "./Switch.jsx";
import PromptModal from "./PromptModal.jsx";
import ConfirmModal from "./ConfirmModal.jsx";
import { PaletteRoles } from "../ThemeRoles.js";
export default function SettingsPanel({
@@ -42,6 +44,8 @@ export default function SettingsPanel({
const [NewThemeName, SetNewThemeName] = useState("");
const [NewThemePalette, SetNewThemePalette] = useState(null);
const [ThemeError, SetThemeError] = useState(null);
const [IsRenamingTheme, SetIsRenamingTheme] = useState(false);
const [IsConfirmingThemeDelete, SetIsConfirmingThemeDelete] = useState(false);
const [Users, SetUsers] = useState(null);
const [NewUsername, SetNewUsername] = useState("");
@@ -68,6 +72,7 @@ export default function SettingsPanel({
const [NewGroupName, SetNewGroupName] = useState("");
const [GroupError, SetGroupError] = useState(null);
const [ExpandedGroup, SetExpandedGroup] = useState(null);
const [ConfirmingDeleteGroupName, SetConfirmingDeleteGroupName] = useState(null);
useEffect(() => {
FetchSettings().then(SetSettings);
@@ -127,31 +132,38 @@ export default function SettingsPanel({
}
}
async function HandleRenameTheme() {
const NewName = window.prompt("Rename theme to:", Settings.ThemeName);
if (!NewName || !NewName.trim() || NewName.trim() === Settings.ThemeName) return;
function HandleRenameThemeClick() {
SetIsRenamingTheme(true);
}
async function HandleRenameThemeConfirm(NewName) {
SetIsRenamingTheme(false);
if (NewName === Settings.ThemeName) return;
SetThemeError(null);
try {
const Palette = await FetchTheme(Settings.ThemeName);
await SaveTheme(NewName.trim(), Palette);
await SaveTheme(NewName, Palette);
await DeleteTheme(Settings.ThemeName);
const Updated = await FetchThemeNames();
SetThemeNames(Updated);
UpdateField("ThemeName", NewName.trim());
UpdateField("ThemeName", NewName);
} catch (Error) {
SetThemeError(Error.message);
}
}
async function HandleDeleteTheme() {
function HandleDeleteThemeClick() {
if (ThemeNames.length <= 1) {
SetThemeError("Can't delete the only remaining theme.");
return;
}
if (!window.confirm(`Delete theme "${Settings.ThemeName}"? This can't be undone.`)) return;
SetIsConfirmingThemeDelete(true);
}
async function HandleDeleteThemeConfirm() {
SetIsConfirmingThemeDelete(false);
SetThemeError(null);
try {
await DeleteTheme(Settings.ThemeName);
@@ -260,8 +272,13 @@ export default function SettingsPanel({
}
}
async function HandleDeleteGroup(Name) {
if (!window.confirm(`Delete group "${Name}"? This can't be undone.`)) return;
function HandleDeleteGroupClick(Name) {
SetConfirmingDeleteGroupName(Name);
}
async function HandleDeleteGroupConfirm() {
const Name = ConfirmingDeleteGroupName;
SetConfirmingDeleteGroupName(null);
SetGroupError(null);
try {
@@ -392,8 +409,8 @@ export default function SettingsPanel({
))}
</select>
<div className="SettingsRowInline">
<button onClick={HandleRenameTheme}>Rename</button>
<button onClick={HandleDeleteTheme}>Delete</button>
<button onClick={HandleRenameThemeClick}>Rename</button>
<button onClick={HandleDeleteThemeClick}>Delete</button>
</div>
</div>
@@ -610,8 +627,8 @@ export default function SettingsPanel({
>
{ExpandedGroup === Group.Name ? "Hide" : "Members"}
</button>
{Group.Name !== "Admin" && (
<button onClick={() => HandleDeleteGroup(Group.Name)}>Delete</button>
{Group.Name !== "Admin" && Group.Name !== "Guest" && (
<button onClick={() => HandleDeleteGroupClick(Group.Name)}>Delete</button>
)}
</div>
</div>
@@ -658,6 +675,35 @@ export default function SettingsPanel({
</button>
</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>
);
}
+50 -12
View File
@@ -12,6 +12,9 @@ import {
} from "../Api.js";
import { GetFileIcon, GetFolderIcon } from "../IconStyle.js";
import AccessModal from "./AccessModal.jsx";
import PromptModal from "./PromptModal.jsx";
import ConfirmModal from "./ConfirmModal.jsx";
import ErrorToast from "./ErrorToast.jsx";
function TreeNode({
RootName,
@@ -34,6 +37,9 @@ function TreeNode({
}) {
const [IsExpanded, SetIsExpanded] = useState(false);
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 IsSelected = SelectedFile === FullPath;
const IsCurrentDirectory = Entry.IsDirectory && CurrentDirectoryPath === FullPath;
@@ -82,10 +88,14 @@ function TreeNode({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [CurrentDirectoryPath]);
async function HandleRename(Event) {
function HandleRenameClick(Event) {
Event.stopPropagation();
const NewValue = window.prompt("Rename to (or type a path to move):", Entry.Name);
if (!NewValue || NewValue === Entry.Name) return;
SetIsRenaming(true);
}
async function HandleRenameConfirm(NewValue) {
SetIsRenaming(false);
if (NewValue === Entry.Name) return;
try {
if (NewValue.includes("/")) {
@@ -97,22 +107,28 @@ function TreeNode({
OnEntryRenamed(FullPath, NewPath, Entry.IsDirectory);
}
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
async function HandleDelete(Event) {
function HandleDeleteClick(Event) {
Event.stopPropagation();
if (ConfirmBeforeDelete && !window.confirm(`Delete "${Entry.Name}"? This can't be undone.`)) {
return;
if (ConfirmBeforeDelete) {
SetIsConfirmingDelete(true);
} else {
HandleDeleteConfirm();
}
}
async function HandleDeleteConfirm() {
SetIsConfirmingDelete(false);
try {
await DeleteEntry(RootName, FullPath);
OnEntryDeleted(FullPath, Entry.IsDirectory);
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -140,15 +156,35 @@ function TreeNode({
<button className="TreeRowActionButton" onClick={HandleLockClick} title="Access control">
🔒
</button>
<button className="TreeRowActionButton" onClick={HandleRename} title="Rename / move">
<button className="TreeRowActionButton" onClick={HandleRenameClick} title="Rename / move">
✎
</button>
<button className="TreeRowActionButton" onClick={HandleDelete} title="Delete">
<button className="TreeRowActionButton" onClick={HandleDeleteClick} title="Delete">
✕
</button>
</span>
)}
</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 && (
<div className="TreeChildren">
{Children.map((Child) => (
@@ -247,6 +283,7 @@ export default function Sidebar({
const [RootEntries, SetRootEntries] = useState([]);
const [LoadError, SetLoadError] = useState(null);
const [ModalConfig, SetModalConfig] = useState(null);
const [ErrorMessage, SetErrorMessage] = useState(null);
useEffect(() => {
if (RootName) {
@@ -280,7 +317,7 @@ export default function Sidebar({
InitialGroups: (Existing && Existing.Groups) || [],
});
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -304,7 +341,7 @@ export default function Sidebar({
SetModalConfig(null);
} catch (Error) {
window.alert(Error.message);
SetErrorMessage(Error.message);
}
}
@@ -396,6 +433,7 @@ export default function Sidebar({
OnCancel={() => SetModalConfig(null)}
/>
)}
<ErrorToast Message={ErrorMessage} OnDismiss={() => SetErrorMessage(null)} />
</div>
);
}
+32
View File
@@ -784,6 +784,38 @@ img,
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 {
color: var(--TextMuted);
font-size: 12px;