mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 10:38:11 +02:00
313 lines
16 KiB
Python
313 lines
16 KiB
Python
"""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)
|