mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Client: your server settings, profile, account and admin in Settings
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e2cb25e2e1
commit
c7a7596e99
3 files changed
+401
-2
No files matched your search
@@ -0,0 +1,312 @@
|
||||
"""Your SaraView account and settings on the server, in Settings while you're
|
||||
logged in to one: the web view's Settings panel (Frontend/src/Components/
|
||||
SettingsPanel.jsx), section for section, through the same /api endpoints.
|
||||
|
||||
- Web view: your settings on the server (saved with Settings' Save). Most
|
||||
are how the web view looks; hidden files and .sv_ notes also apply here.
|
||||
- Profile (colour, avatar), Account (username, password, log out) and, for
|
||||
admins, Users, Groups and Locked directories: these take effect at once,
|
||||
like the web view's."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import flet as ft
|
||||
|
||||
from Core import ChipButton, Palette
|
||||
from Diagrams import ThemePalette
|
||||
from Sources import ServerSource, SourceError
|
||||
|
||||
|
||||
def _Note(Text: str) -> ft.Text:
|
||||
return ft.Text(Text, size=12, color=Palette.Overlay0)
|
||||
|
||||
|
||||
def _Heading(Text: str) -> ft.Text:
|
||||
return ft.Text(Text, color=Palette.Mauve)
|
||||
|
||||
|
||||
def _Divider() -> ft.Divider:
|
||||
return ft.Divider(height=1, color=Palette.Surface1)
|
||||
|
||||
|
||||
class ServerSections:
|
||||
"""Builds the sections (Categories) and saves the Web view ones (Save)."""
|
||||
|
||||
def __init__(self, TheApp, OnLoggedOut: Callable[[], None]):
|
||||
self.TheApp = TheApp
|
||||
self.Server: ServerSource = TheApp.Server
|
||||
self.OnLoggedOut = OnLoggedOut
|
||||
self.Values = self.Server.UserSettings() # raises SourceError: then there are no sections
|
||||
self.ThemeNames = self.Server.ThemeNames()
|
||||
self.IsAdmin = bool((self.Server.User or {}).get("IsAdmin"))
|
||||
self.Switches: dict[str, ft.Switch] = {}
|
||||
self.Categories: dict[str, ft.Control] = {
|
||||
"Web view": self._WebView(),
|
||||
"Profile": self._Profile(),
|
||||
"Account": self._Account(),
|
||||
}
|
||||
if self.IsAdmin:
|
||||
self.UsersList = ft.Column(spacing=6)
|
||||
self.GroupsList = ft.Column(spacing=10)
|
||||
self.LocksList = ft.Column(spacing=6)
|
||||
self.Categories["Users"] = self._Users()
|
||||
self.Categories["Groups"] = self._Groups()
|
||||
self.Categories["Locked directories"] = self._Locks()
|
||||
self._RefreshAdmin(Update=False)
|
||||
|
||||
def _Try(self, Action: Callable[[], None], Failure: str) -> bool:
|
||||
try:
|
||||
Action()
|
||||
return True
|
||||
except SourceError as Error:
|
||||
self.TheApp.Notify(f"{Failure}: {Error}", "error")
|
||||
return False
|
||||
|
||||
# --- Web view (saved with Settings' Save) -------------------------------
|
||||
|
||||
def _Switch(self, Key: str, Label: str) -> ft.Switch:
|
||||
self.Switches[Key] = ft.Switch(label=Label, value=bool(self.Values.get(Key)))
|
||||
return self.Switches[Key]
|
||||
|
||||
def _WebView(self) -> ft.Control:
|
||||
V = self.Values
|
||||
self.MarkdownEditing = ft.Dropdown(label="Markdown editing", value=V.get("MarkdownEditing", "Live"), options=[
|
||||
ft.DropdownOption(key="Live", text="Live: edit the Markdown where you click in the rendered file"),
|
||||
ft.DropdownOption(key="Separate", text="Separate: View and Edit are two modes")])
|
||||
self.ThemePicker = ft.Dropdown(label="Colour theme", value=V.get("ThemeName"),
|
||||
options=[ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames])
|
||||
self.NewThemeName = ft.TextField(label="New theme name (a copy of the selected one)", dense=True, expand=True)
|
||||
self.FontFamily = ft.TextField(label="Font family", value=V.get("FontFamily", ""))
|
||||
self.FontSize = ft.TextField(label="Font size (px)", value=str(V.get("FontSize", 14)),
|
||||
keyboard_type=ft.KeyboardType.NUMBER)
|
||||
self.PanButton = ft.Dropdown(label="Graph: pan with mouse button", value=V.get("GraphPanButton", "Right"), options=[
|
||||
ft.DropdownOption(key="Right", text="Right (left-click opens a dot)"),
|
||||
ft.DropdownOption(key="Left", text="Left (right-click opens a dot)")])
|
||||
return ft.Column([
|
||||
_Note("Your settings on the server, the same as the web view's. Most change the web view; "
|
||||
"hidden directories and .sv_ notes also apply here while connected."),
|
||||
_Heading("Files"),
|
||||
self._Switch("ShowHiddenFolders", "Show hidden directories"),
|
||||
self._Switch("ShowSvFiles", "Show directory note files (.sv_*)"),
|
||||
self._Switch("ShowBreadcrumbs", "Show breadcrumbs"),
|
||||
self._Switch("UseHyphenBullets", "Hyphen file icons (-)"),
|
||||
self._Switch("UseAsciiFolderIcons", "ASCII directory icons (>/v)"),
|
||||
self._Switch("NavigateIntoDirectories", "Open directory file on click"),
|
||||
self._Switch("NewEntryButtonsAtTop", "New File/Directory buttons at top of sidebar"),
|
||||
self._Switch("ConfirmBeforeDelete", "Confirm before delete"),
|
||||
_Divider(), _Heading("Editor"),
|
||||
self.MarkdownEditing,
|
||||
self._Switch("StickyToolbar", "Sticky edit/view/save toolbar"),
|
||||
self._Switch("ShowGitBlame", "Show blame gutter (who last changed each line)"),
|
||||
self._Switch("ShowBlameAvatars", "Show avatars instead of names in blame"),
|
||||
self._Switch("VisibleInBlame", "Show my edits in others' blame view"),
|
||||
_Divider(), _Heading("Appearance"),
|
||||
self.ThemePicker,
|
||||
ft.Row([self.NewThemeName, ChipButton("Create", lambda e: self._CreateTheme())]),
|
||||
ft.Row([ChipButton("Delete selected theme", lambda e: self._DeleteTheme(), Accent=Palette.Red)]),
|
||||
self.FontFamily, self.FontSize,
|
||||
_Divider(), _Heading("Graph"),
|
||||
self._Switch("GraphShowDirectories", "Show directories (off: only files and the links between them)"),
|
||||
self._Switch("GraphColourByDepth", "Colour directories by depth (off: all purple)"),
|
||||
self._Switch("GraphShrinkByDepth", "Smaller directory dots the deeper they are"),
|
||||
self.PanButton,
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def _RefreshThemes(self, Selected: str) -> None:
|
||||
self.ThemeNames = self.Server.ThemeNames()
|
||||
self.ThemePicker.options = [ft.DropdownOption(key=Name, text=Name) for Name in self.ThemeNames]
|
||||
self.ThemePicker.value = Selected if Selected in self.ThemeNames else self.ThemeNames[0]
|
||||
self.ThemePicker.update()
|
||||
|
||||
def _CreateTheme(self) -> None:
|
||||
Name = (self.NewThemeName.value or "").strip()
|
||||
Base = self.ThemePicker.value
|
||||
if not Name or not Base:
|
||||
return
|
||||
if self._Try(lambda: self.Server.SaveTheme(Name, self.Server.Theme(Base)), "Couldn't create the theme"):
|
||||
self.NewThemeName.value = ""
|
||||
self.NewThemeName.update()
|
||||
self._RefreshThemes(Name)
|
||||
|
||||
def _DeleteTheme(self) -> None:
|
||||
Name = self.ThemePicker.value
|
||||
if not Name:
|
||||
return
|
||||
if len(self.ThemeNames) <= 1:
|
||||
self.TheApp.Notify("Can't delete the only remaining theme.", "error")
|
||||
return
|
||||
if self._Try(lambda: self.Server.DeleteTheme(Name), "Couldn't delete the theme"):
|
||||
self._RefreshThemes("")
|
||||
|
||||
def Save(self) -> bool:
|
||||
"""Saves the Web view section; False (after saying why) if it couldn't."""
|
||||
Values = dict(self.Values)
|
||||
for Key, Switch in self.Switches.items():
|
||||
Values[Key] = bool(Switch.value)
|
||||
Values["MarkdownEditing"] = self.MarkdownEditing.value or Values.get("MarkdownEditing", "Live")
|
||||
Values["ThemeName"] = self.ThemePicker.value or Values.get("ThemeName")
|
||||
Values["FontFamily"] = (self.FontFamily.value or "").strip() or Values.get("FontFamily")
|
||||
try:
|
||||
Values["FontSize"] = max(8, min(40, int(self.FontSize.value)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
Values["GraphPanButton"] = self.PanButton.value or Values.get("GraphPanButton", "Right")
|
||||
return self._Try(lambda: self.Server.SaveUserSettings(Values), "Couldn't save your server settings")
|
||||
|
||||
# --- Profile and Account (at once) ---------------------------------------
|
||||
|
||||
def _Profile(self) -> ft.Control:
|
||||
Colours = ThemePalette()
|
||||
Current = (self.Server.User or {}).get("Colour", "Purple")
|
||||
self.Swatches = ft.Row(wrap=True, spacing=8, run_spacing=8)
|
||||
|
||||
def Pick(Colour: str) -> None:
|
||||
if self._Try(lambda: self.Server.SetColour(Colour), "Couldn't change your colour"):
|
||||
Draw(Colour)
|
||||
self.Swatches.update()
|
||||
|
||||
def Draw(Selected: str) -> None:
|
||||
self.Swatches.controls = [
|
||||
ft.Container(width=28, height=28, border_radius=14, bgcolor=Colours.get(Name, Palette.Mauve),
|
||||
border=ft.Border.all(3, Palette.Text) if Name == Selected else None,
|
||||
tooltip=Name, on_click=lambda e, N=Name: Pick(N))
|
||||
for Name in self.Server.ColourOptions()
|
||||
]
|
||||
|
||||
Draw(Current)
|
||||
|
||||
async def PickAvatar(e) -> None:
|
||||
Picked = await self.TheApp.Picker.pick_files(dialog_title="Avatar", allow_multiple=False,
|
||||
file_type=ft.FilePickerFileType.IMAGE)
|
||||
if Picked and Picked[0].path:
|
||||
if self._Try(lambda: self.Server.UploadAvatar(Path(Picked[0].path)), "Couldn't upload the avatar"):
|
||||
self.TheApp.Notify("Avatar updated.", "success")
|
||||
|
||||
return ft.Column([
|
||||
ft.Text("Your colour"), _Note("Shown on your edits in blame, and on your badge."), self.Swatches,
|
||||
_Divider(), ft.Text("Avatar"), ChipButton("Choose an image…", PickAvatar, Icon=ft.Icons.IMAGE_OUTLINED),
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def _Account(self) -> ft.Control:
|
||||
User = self.Server.User or {}
|
||||
UsernameField = ft.TextField(label="Username", value=User.get("Username", ""))
|
||||
CurrentPassword = ft.TextField(label="Current password", password=True, can_reveal_password=True)
|
||||
NewPassword = ft.TextField(label="New password", password=True, can_reveal_password=True)
|
||||
|
||||
def ChangeName(e) -> None:
|
||||
Name = (UsernameField.value or "").strip()
|
||||
if Name and Name != (self.Server.User or {}).get("Username") and \
|
||||
self._Try(lambda: self.Server.ChangeUsername(Name), "Couldn't change your username"):
|
||||
self.TheApp.Notify(f"You're now {Name}.", "success")
|
||||
|
||||
def ChangePassword(e) -> None:
|
||||
if self._Try(lambda: self.Server.ChangePassword(CurrentPassword.value or "", NewPassword.value or ""),
|
||||
"Couldn't change your password"):
|
||||
CurrentPassword.value = NewPassword.value = ""
|
||||
CurrentPassword.update()
|
||||
NewPassword.update()
|
||||
self.TheApp.Notify("Password changed.", "success")
|
||||
|
||||
return ft.Column([
|
||||
_Note(f"Logged in to {self.Server.Url} as {User.get('Username', '?')}"
|
||||
+ (" (admin)." if self.IsAdmin else ".")),
|
||||
ft.Row([ft.Container(UsernameField, expand=True), ChipButton("Change", ChangeName)]),
|
||||
_Divider(),
|
||||
CurrentPassword, NewPassword, ft.Row([ChipButton("Change password", ChangePassword)]),
|
||||
_Divider(),
|
||||
ft.Row([ChipButton("Log out", lambda e: self.OnLoggedOut(), Icon=ft.Icons.LOGOUT, Accent=Palette.Red)]),
|
||||
], spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
# --- Admin (at once) ------------------------------------------------------
|
||||
|
||||
def _RefreshAdmin(self, Update: bool = True) -> None:
|
||||
try:
|
||||
Users, Groups, Locks = self.Server.Users(), self.Server.Groups(), self.Server.Locks()
|
||||
except SourceError as Error:
|
||||
self.TheApp.Notify(f"Couldn't load the admin lists: {Error}", "error")
|
||||
return
|
||||
Colours = ThemePalette()
|
||||
self.UsersList.controls = [
|
||||
ft.Row([ft.Icon(ft.Icons.CIRCLE, size=12, color=Colours.get(U.get("Colour", "Purple"), Palette.Mauve)),
|
||||
ft.Text(U["Username"]), ft.Text("admin", color=Palette.Overlay0, size=12) if U.get("IsAdmin") else ft.Container()],
|
||||
spacing=8)
|
||||
for U in Users
|
||||
]
|
||||
Names = [U["Username"] for U in Users]
|
||||
self.GroupsList.controls = [self._GroupRow(G, Names) for G in Groups] or [_Note("No groups yet.")]
|
||||
self.LocksList.controls = [
|
||||
ft.Row([ft.Text(f"{L.get('Root')}/{L.get('Path')}", expand=True),
|
||||
ft.Text(", ".join([*(L.get("Users") or []), *(f"@{G}" for G in L.get("Groups") or [])]),
|
||||
color=Palette.Overlay0, size=12),
|
||||
ChipButton("Unlock", lambda e, L=L: self._Admin(lambda: self.Server.RemoveLock(L["Root"], L["Path"]),
|
||||
"Couldn't unlock"))])
|
||||
for L in Locks
|
||||
] or [_Note("Nothing is locked.")]
|
||||
if Update:
|
||||
for Listing in (self.UsersList, self.GroupsList, self.LocksList):
|
||||
try:
|
||||
Listing.update()
|
||||
except RuntimeError:
|
||||
pass # its section isn't open: it shows the new list when it is
|
||||
|
||||
def _Admin(self, Action: Callable[[], None], Failure: str) -> None:
|
||||
if self._Try(Action, Failure):
|
||||
self._RefreshAdmin()
|
||||
|
||||
def _GroupRow(self, Group: dict, Usernames: list[str]) -> ft.Control:
|
||||
Members = Group.get("Members") or []
|
||||
|
||||
def Toggle(Name: str) -> None:
|
||||
New = [M for M in Members if M != Name] if Name in Members else [*Members, Name]
|
||||
self._Admin(lambda: self.Server.SetGroupMembers(Group["Name"], New), "Couldn't change the group")
|
||||
|
||||
Chips = [ft.Container(ft.Text(Name, size=12, color=Palette.Mauve if Name in Members else Palette.Overlay0),
|
||||
bgcolor=Palette.Surface0 if Name in Members else None,
|
||||
border=ft.Border.all(1, Palette.Surface1), border_radius=4,
|
||||
padding=ft.Padding.symmetric(horizontal=6, vertical=2),
|
||||
tooltip="Click to add or remove", on_click=lambda e, N=Name: Toggle(N))
|
||||
for Name in Usernames]
|
||||
return ft.Column([
|
||||
ft.Row([ft.Text(Group["Name"], expand=True),
|
||||
ChipButton("Delete", lambda e: self.TheApp.Confirm(
|
||||
"Delete group", f'Delete group "{Group["Name"]}"? This can\'t be undone.',
|
||||
lambda: self._Admin(lambda: self.Server.DeleteGroup(Group["Name"]), "Couldn't delete the group")),
|
||||
Accent=Palette.Red)]),
|
||||
ft.Row(Chips, wrap=True, spacing=4, run_spacing=4),
|
||||
], spacing=4)
|
||||
|
||||
def _Users(self) -> ft.Control:
|
||||
Name = ft.TextField(label="Username", dense=True)
|
||||
Password = ft.TextField(label="Password", password=True, can_reveal_password=True, dense=True)
|
||||
IsAdmin = ft.Switch(label="Admin", value=False)
|
||||
Colour = ft.Dropdown(label="Colour", value="Purple", dense=True,
|
||||
options=[ft.DropdownOption(key=C, text=C) for C in self.Server.ColourOptions()])
|
||||
|
||||
def Create(e) -> None:
|
||||
if (Name.value or "").strip():
|
||||
self._Admin(lambda: self.Server.CreateUser(Name.value.strip(), Password.value or "", bool(IsAdmin.value),
|
||||
Colour.value or "Purple"), "Couldn't create the user")
|
||||
Name.value = Password.value = ""
|
||||
Name.update()
|
||||
Password.update()
|
||||
|
||||
return ft.Column([self.UsersList, _Divider(), ft.Text("New user"), Name, Password, ft.Row([IsAdmin, Colour]),
|
||||
ft.Row([ChipButton("Create user", Create)])], spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def _Groups(self) -> ft.Control:
|
||||
Name = ft.TextField(label="New group name", dense=True, expand=True)
|
||||
|
||||
def Create(e) -> None:
|
||||
if (Name.value or "").strip():
|
||||
self._Admin(lambda: self.Server.CreateGroup(Name.value.strip()), "Couldn't create the group")
|
||||
Name.value = ""
|
||||
Name.update()
|
||||
|
||||
return ft.Column([_Note("Click a name to add it to the group or take it out."), self.GroupsList,
|
||||
_Divider(), ft.Row([Name, ChipButton("Create group", Create)])],
|
||||
spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
|
||||
def _Locks(self) -> ft.Control:
|
||||
return ft.Column([_Note("Directories only some users or groups can see."), self.LocksList],
|
||||
spacing=12, scroll=ft.ScrollMode.AUTO)
|
||||
Reference in new issue
Block a user