From 104198036a44f400a79d29d03c681d627d057d0d Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Mon, 21 Sep 2026 21:38:12 +0200 Subject: [PATCH] Added modals for popups, and fixed root-level bug. --- Backend/Core.py | 16 +++++ Backend/Main.py | 7 ++- Backend/Portals.py | 24 ++------ Frontend/src/App.jsx | 5 +- Frontend/src/Components/ConfirmModal.jsx | 28 +++++++++ Frontend/src/Components/DirectoryPane.jsx | 62 +++++++++++++++---- Frontend/src/Components/ErrorToast.jsx | 14 +++++ Frontend/src/Components/PromptModal.jsx | 52 ++++++++++++++++ Frontend/src/Components/SettingsPanel.jsx | 72 +++++++++++++++++++---- Frontend/src/Components/Sidebar.jsx | 62 +++++++++++++++---- Frontend/src/Styles/Theme.css | 32 ++++++++++ 11 files changed, 312 insertions(+), 62 deletions(-) create mode 100644 Frontend/src/Components/ConfirmModal.jsx create mode 100644 Frontend/src/Components/ErrorToast.jsx create mode 100644 Frontend/src/Components/PromptModal.jsx diff --git a/Backend/Core.py b/Backend/Core.py index 23181b8..fe3d1de 100644 --- a/Backend/Core.py +++ b/Backend/Core.py @@ -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", {}) diff --git a/Backend/Main.py b/Backend/Main.py index 6513892..ca2b672 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -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.") diff --git a/Backend/Portals.py b/Backend/Portals.py index c6c81ad..d42e3ef 100644 --- a/Backend/Portals.py +++ b/Backend/Portals.py @@ -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: diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 730bb83..7f4f8be 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -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} /> )} + SetErrorMessage(null)} /> ); } diff --git a/Frontend/src/Components/ConfirmModal.jsx b/Frontend/src/Components/ConfirmModal.jsx new file mode 100644 index 0000000..cac02af --- /dev/null +++ b/Frontend/src/Components/ConfirmModal.jsx @@ -0,0 +1,28 @@ +import React from "react"; + +export default function ConfirmModal({ Title, Message, ConfirmLabel, OnConfirm, OnCancel }) { + function HandleOverlayClick(Event) { + Event.stopPropagation(); + OnCancel(); + } + + return ( +
+
Event.stopPropagation()}> +
+ {Title || "Confirm"} + +
+
+
{Message}
+
+
+ + +
+
+
+ ); +} diff --git a/Frontend/src/Components/DirectoryPane.jsx b/Frontend/src/Components/DirectoryPane.jsx index 365b4c8..a65f731 100644 --- a/Frontend/src/Components/DirectoryPane.jsx +++ b/Frontend/src/Components/DirectoryPane.jsx @@ -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({ + + ); +} diff --git a/Frontend/src/Components/PromptModal.jsx b/Frontend/src/Components/PromptModal.jsx new file mode 100644 index 0000000..69bc2db --- /dev/null +++ b/Frontend/src/Components/PromptModal.jsx @@ -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 ( +
+
Event.stopPropagation()}> +
+ {Title} + +
+
+
+ {Label && } + SetValue(Event.target.value)} + onKeyDown={HandleKeyDown} + autoFocus + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/Frontend/src/Components/SettingsPanel.jsx b/Frontend/src/Components/SettingsPanel.jsx index c0f5500..bcc801e 100644 --- a/Frontend/src/Components/SettingsPanel.jsx +++ b/Frontend/src/Components/SettingsPanel.jsx @@ -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({ ))}
- - + +
@@ -610,8 +627,8 @@ export default function SettingsPanel({ > {ExpandedGroup === Group.Name ? "Hide" : "Members"} - {Group.Name !== "Admin" && ( - + {Group.Name !== "Admin" && Group.Name !== "Guest" && ( + )} @@ -658,6 +675,35 @@ export default function SettingsPanel({ + + {IsRenamingTheme && ( + SetIsRenamingTheme(false)} + /> + )} + {IsConfirmingThemeDelete && ( + SetIsConfirmingThemeDelete(false)} + /> + )} + {ConfirmingDeleteGroupName && ( + SetConfirmingDeleteGroupName(null)} + /> + )} ); } diff --git a/Frontend/src/Components/Sidebar.jsx b/Frontend/src/Components/Sidebar.jsx index 17191b4..932fa76 100644 --- a/Frontend/src/Components/Sidebar.jsx +++ b/Frontend/src/Components/Sidebar.jsx @@ -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({ - - )} + {IsRenaming && ( + SetIsRenaming(false)} + /> + )} + {IsConfirmingDelete && ( + SetIsConfirmingDelete(false)} + /> + )} + SetErrorMessage(null)} /> {IsExpanded && Children && (
{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)} /> )} + SetErrorMessage(null)} />
); } diff --git a/Frontend/src/Styles/Theme.css b/Frontend/src/Styles/Theme.css index b80b955..d118713 100644 --- a/Frontend/src/Styles/Theme.css +++ b/Frontend/src/Styles/Theme.css @@ -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;