Files
Orbit/MainWindow.cs
T

222 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Net.Http;
namespace ShortcutsPanel;
public record Shortcut(string Name, string Url, string Group, string Color, string? Icon = null, string Id = "");
public partial class MainWindow : Window
{
readonly SettingsStore settings;
readonly WrapPanel cards = new();
readonly StackPanel groups = new();
readonly TextBlock heading = new() { FontSize = 30, FontWeight = FontWeights.SemiBold };
static readonly Dictionary<string, Task<BitmapSource?>> logos = new();
readonly TextBox search = new() { Width = 280, ToolTip = "Search by name, website, or collection" };
readonly TextBlock status = new() { Foreground = Brush("#939DB8"), FontSize = 12 };
List<Shortcut> entries = [];
string selectedGroup = "All";
List<string> collectionOrder = [];
readonly Stack<(Shortcut Item, int Index)> deleted = new();
Button? undoButton;
bool readable = true;
static readonly string[] Palette = ["#A697FF", "#60D6B2", "#6CB8FF", "#FFB878", "#F58EB9", "#DDD17B"];
public MainWindow(SettingsStore? store = null)
{
settings = store ?? new SettingsStore();
try { Theme.Apply(settings.LoadDarkTheme()); }
catch { Theme.Apply(false); }
Background = Brush("#FAF9F6"); Foreground = Brush("#252924"); FontFamily = new FontFamily("Segoe UI");
Title = "Orbit • Shortcuts Panel"; Width = 1100; Height = 720; MinWidth = 800; MinHeight = 520;
WindowStartupLocation = WindowStartupLocation.CenterScreen; WindowState = WindowState.Maximized;
var root = new Grid(); Content = root;
root.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(210) });
root.ColumnDefinitions.Add(new ColumnDefinition());
var sidebar = new DockPanel { Margin = new Thickness(20, 32, 20, 24) };
var sideSurface = new Border { Background = Brush("#F1F2ED"), BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,0,1,0), Child = sidebar }; root.Children.Add(sideSurface);
var brand = new StackPanel { Margin = new Thickness(12, 0, 0, 36) };
brand.Children.Add(new TextBlock { Text = "◉ orbit", FontSize = 27, FontWeight = FontWeights.SemiBold, Foreground = Brush("#294B3C") });
brand.Children.Add(new TextBlock { Text = "A little more organized.", FontSize = 11, Foreground = Brush("#80877D"), Margin = new Thickness(1, 6, 0, 0) });
DockPanel.SetDock(brand, Dock.Top); sidebar.Children.Add(brand);
var bottom = new StackPanel();
var bottomSurface = new Border { BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,1,0,0), Padding = new Thickness(0,18,0,0), Child = bottom }; DockPanel.SetDock(bottomSurface, Dock.Bottom); sidebar.Children.Add(bottomSurface);
var themeSwitch = new CheckBox { Content = "Dark mode", IsChecked = Theme.IsDark, Margin = new Thickness(12, 0, 0, 0), VerticalContentAlignment = VerticalAlignment.Center };
themeSwitch.SetResourceReference(StyleProperty, "ThemeSwitch");
themeSwitch.Click += (_, _) =>
{
Theme.Apply(themeSwitch.IsChecked == true);
try { settings.SaveDarkTheme(Theme.IsDark); }
catch (Exception ex) { MessageBox.Show(this, $"Theme changed for this session, but Windows could not save the preference.\n\n{ex.Message}", "Theme preference"); }
};
bottom.Children.Add(SidebarButton("Import / Export", "\uE8B5", Transfer));
bottom.Children.Add(SidebarButton("Collections", "\uE8F1", ManageCollections));
bottom.Children.Add(SidebarButton("About Orbit", "\uE946", About));
bottom.Children.Add(SidebarButton("Open settings", "\uE8B7", () =>
{
try { settings.OpenLocation(); }
catch (Exception ex) { MessageBox.Show(this, "Could not open the settings folder.\n\n" + ex.Message, "Open settings"); }
}));
bottom.Children.Add(new Border { BorderBrush = Brush("#E2E5DD"), BorderThickness = new Thickness(0,1,0,0), Margin = new Thickness(0,14,0,0), Padding = new Thickness(0,18,0,0), Child = themeSwitch });
var nav = new DockPanel(); sidebar.Children.Add(nav);
var label = new TextBlock { Text = "LIBRARY", FontSize = 10, FontWeight = FontWeights.SemiBold, Foreground = Brush("#838B7F"), Margin = new Thickness(12, 0, 0, 14) }; DockPanel.SetDock(label, Dock.Top); nav.Children.Add(label);
nav.Children.Add(new ScrollViewer { Content = groups, VerticalScrollBarVisibility = ScrollBarVisibility.Auto });
var main = new DockPanel { Margin = new Thickness(36, 36, 28, 24) }; Grid.SetColumn(main, 1); root.Children.Add(main);
var top = new StackPanel(); DockPanel.SetDock(top, Dock.Top); main.Children.Add(top);
var header = new DockPanel(); top.Children.Add(header);
var add = Button("+ Add website", () => Edit(null), "#294B3D"); add.Foreground = Brushes.White; add.VerticalAlignment = VerticalAlignment.Center; DockPanel.SetDock(add, Dock.Right); header.Children.Add(add);
header.Children.Add(heading);
top.Children.Add(new TextBlock { Text = "Your favorite places, thoughtfully collected.", FontSize = 13, Foreground = Brush("#7B8377"), Margin = new Thickness(0, 9, 0, 26) });
search.Width = double.NaN; search.HorizontalAlignment = HorizontalAlignment.Stretch;
search.ToolTip = "Search websites and collections";
System.Windows.Automation.AutomationProperties.SetName(search, "Search websites");
var searchRow = new DockPanel { Margin = new Thickness(0, 0, 8, 28) };
var searchLabel = new TextBlock { Text = "Search", VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0,0,12,0), Foreground = Brush("#7B8377"), FontSize = 12 }; searchRow.Children.Add(searchLabel); searchRow.Children.Add(search); top.Children.Add(searchRow);
var footer = new DockPanel { Margin = new Thickness(0, 16, 8, 0) }; DockPanel.SetDock(footer, Dock.Bottom); main.Children.Add(footer);
undoButton = Button("Undo deletion", UndoDelete); undoButton.IsEnabled = false;
DockPanel.SetDock(undoButton, Dock.Right); footer.Children.Add(undoButton);
status.Foreground = Brush("#868D80"); footer.Children.Add(status);
main.Children.Add(new ScrollViewer { Content = cards, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled });
search.TextChanged += (_, _) => RenderCards();
Load(); Render();
}
static SolidColorBrush Brush(string color) => Theme.Brush(color);
static Button Button(string text, Action action, string? color = null)
{
var b = new Button { Content = text, FontSize = 13 }; if (color != null) b.Background = Brush(color);
b.Click += (_, _) => action(); return b;
}
void Load()
{
try
{
var json = settings.Load();
entries = json == null
? [new("Google", "https://www.google.com", "Everyday", Palette[0]), new("YouTube", "https://www.youtube.com", "Everyday", Palette[4]), new("GitHub", "https://github.com", "Work", Palette[2]), new("Wikipedia", "https://www.wikipedia.org", "Discover", Palette[1])]
: JsonSerializer.Deserialize<List<Shortcut>>(json) ?? throw new InvalidDataException("Empty settings.");
entries = LibraryOperations.Validate(entries);
collectionOrder = LibraryOperations.Collections(entries, settings.LoadCollections());
}
catch (Exception ex) { readable = false; entries = []; MessageBox.Show(this, $"Could not load your library. Existing data has been preserved.\n\n{ex.Message}", "Settings could not be loaded"); }
}
bool Save(List<Shortcut> updated, List<string>? order = null)
{
if (!readable) { MessageBox.Show(this, "Resolve the library startup error and restart before making changes."); return false; }
try
{
updated = LibraryOperations.Validate(updated);
var nextOrder = LibraryOperations.Collections(updated, order ?? collectionOrder);
settings.SaveLibrary(JsonSerializer.Serialize(updated, new JsonSerializerOptions { WriteIndented = true }), nextOrder);
entries = updated; collectionOrder = nextOrder; Render(); return true;
}
catch (Exception ex) { MessageBox.Show(this, $"Windows could not save changes for this user account. Your previous library has been kept.\n\n{ex.Message}", "Save failed"); return false; }
}
void Render()
{
var names = LibraryOperations.Collections(entries, collectionOrder);
if (!names.Contains(selectedGroup)) selectedGroup = "All";
groups.Children.Clear();
foreach (var name in new[] { "All" }.Concat(names.Where(x => x != "All")))
{
var count = name == "All" ? entries.Count : entries.Count(x => x.Group == name);
var b = Button("", () => { selectedGroup = name; Render(); }, name == selectedGroup ? "#DFE6DA" : "#F1F2ED");
b.HorizontalContentAlignment = HorizontalAlignment.Stretch;
var row = new DockPanel { Width = 138 };
var number = new TextBlock { Text = count.ToString(), Foreground = Brush("#7B8377"), FontSize = 11, VerticalAlignment = VerticalAlignment.Center }; DockPanel.SetDock(number, Dock.Right); row.Children.Add(number);
row.Children.Add(new TextBlock { Text = name == "All" ? "▦ All websites" : "• " + name, FontSize = 12, TextTrimming = TextTrimming.CharacterEllipsis, Foreground = Brush("#374632") });
b.Content = row; b.Margin = new Thickness(0,0,0,6); groups.Children.Add(b);
System.Windows.Automation.AutomationProperties.SetName(b, name == "All" ? "All websites" : name);
if (name != "All") ConfigureCollection(b, name);
}
RenderCards();
}
void RenderCards()
{
cards.Children.Clear();
var filtered = entries.Where(x => (selectedGroup == "All" || x.Group == selectedGroup) && $"{x.Name} {x.Url} {x.Group}".Contains(search.Text.Trim(), StringComparison.OrdinalIgnoreCase)).ToList();
foreach (var item in filtered)
{
var layout = new DockPanel();
var actions = new DockPanel { Margin = new Thickness(0, 10, 0, 0) }; DockPanel.SetDock(actions, Dock.Bottom); layout.Children.Add(actions);
var edit = Button("Edit", () => Edit(item), "#FFFFFF"); edit.Padding = new Thickness(6, 3, 6, 3); edit.Foreground = Brush("#7B8377"); edit.FontSize = 11; edit.SetResourceReference(StyleProperty, "CardButton"); DockPanel.SetDock(edit, Dock.Right); actions.Children.Add(edit);
var tag = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center };
tag.Children.Add(new Border { Width = 5, Height = 5, CornerRadius = new CornerRadius(3), Background = Brush(item.Color), Margin = new Thickness(0,0,6,0) });
tag.Children.Add(new TextBlock { Text = item.Group, Foreground = Brush("#7B8377"), FontSize = 10, MaxWidth = 130, TextTrimming = TextTrimming.CharacterEllipsis }); actions.Children.Add(tag);
var content = new StackPanel { Width = 182 };
var logoRow = new DockPanel { Margin = new Thickness(0,0,0,16) };
var arrow = new TextBlock { Text = "↗", FontSize = 17, Foreground = Brush("#A2AA9B"), VerticalAlignment = VerticalAlignment.Top }; DockPanel.SetDock(arrow, Dock.Right); logoRow.Children.Add(arrow);
var badge = new Border { Background = Brush("#F5F6F2"), CornerRadius = new CornerRadius(10), Width = 42, Height = 42, HorizontalAlignment = HorizontalAlignment.Left,
Child = new TextBlock { Text = System.Globalization.StringInfo.GetNextTextElement(item.Name).ToUpperInvariant(), Foreground = Brush("#53654B"), FontSize = 20, FontWeight = FontWeights.SemiBold, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } };
logoRow.Children.Add(badge); content.Children.Add(logoRow); if (item.Icon != null) badge.Child = new Image { Source = CustomIcons.Decode(item.Icon), Width = 28, Height = 28, Stretch = Stretch.Uniform };
else _ = ApplyLogo(badge, item.Url);
content.Children.Add(new TextBlock { Text = item.Name, Foreground = Brush("#252D23"), FontSize = 16, FontWeight = FontWeights.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis });
content.Children.Add(new TextBlock { Text = new Uri(item.Url).Host.Replace("www.", ""), Foreground = Brush("#8B9285"), FontSize = 11, Margin = new Thickness(0,5,0,0), TextTrimming = TextTrimming.CharacterEllipsis });
var launch = Button("", () => Launch(item), "#FFFFFF"); launch.SetResourceReference(StyleProperty, "CardButton"); launch.Content = content; launch.Padding = new Thickness(0); launch.ToolTip = item.Url;
System.Windows.Automation.AutomationProperties.SetName(launch, "Open " + item.Name); layout.Children.Add(launch);
var card = new Border { Width = 226, Height = 188, Background = Brush("#FFFFFF"), BorderBrush = Brush("#E5E8E0"), BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(12), Padding = new Thickness(20,18,20,14), Margin = new Thickness(0,0,14,14), Child = layout };
cards.Children.Add(card); ConfigureCard(card, item);
}
if (filtered.Count == 0) cards.Children.Add(new TextBlock { Text = entries.Count == 0 ? "A fresh start. Add your first website above." : "No websites match. Try another search or collection.", FontSize = 18, Margin = new Thickness(0, 35, 0, 35) });
heading.Text = selectedGroup == "All" ? "All websites" : selectedGroup;
status.Text = $"{filtered.Count} websites · Opens in your default browser";
}
static async Task ApplyLogo(Border badge, string url)
{
var key = new Uri(url).GetLeftPart(UriPartial.Path);
if (!logos.TryGetValue(key, out var task)) logos[key] = task = WebsiteIcons.Fetch(key);
var bitmap = await task;
if (bitmap != null) badge.Child = new Image { Source = bitmap, Width = 26, Height = 26, Stretch = Stretch.Uniform };
}
void Launch(Shortcut item)
{
try { if (!ValidUrl(item.Url, out var url)) throw new Exception("Only HTTP and HTTPS websites are supported."); Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); }
catch (Exception ex) { MessageBox.Show(this, ex.Message, "Could not open website"); }
}
public static bool ValidUrl(string? input, out string result)
{
result = ""; if (string.IsNullOrWhiteSpace(input)) return false;
var value = input.Trim(); if (!value.Contains("://")) value = "https://" + value;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || (uri.Scheme != "http" && uri.Scheme != "https") || string.IsNullOrEmpty(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return false;
result = uri.AbsoluteUri; return true;
}
void Edit(Shortcut? original)
{
var dialog = new Window { Owner = this, Title = original == null ? "Add website" : "Edit website", Width = 480, Height = 690, ResizeMode = ResizeMode.NoResize, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = true };
var panel = new StackPanel { Margin = new Thickness(28) }; dialog.Content = new ScrollViewer { Content = panel, VerticalScrollBarVisibility = ScrollBarVisibility.Auto };
panel.Children.Add(new TextBlock { Text = original == null ? "A new destination." : "Make it yours.", FontSize = 26, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0, 0, 0, 18) });
TextBox Field(string label, string value) { panel.Children.Add(new TextBlock { Text = label, Margin = new Thickness(0, 10, 0, 6) }); var t = new TextBox { Text = value, MaxLength = label == "Website URL" ? 2048 : 60 }; panel.Children.Add(t); return t; }
var name = Field("Name", original?.Name ?? ""); var url = Field("Website URL", original?.Url ?? ""); var group = Field("Collection", original?.Group ?? "Everyday");
panel.Children.Add(new TextBlock { Text = "Accent color", Margin = new Thickness(0, 16, 0, 8) });
var colors = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(colors); string color = original?.Color ?? Palette[0];
void DrawColors() { colors.Children.Clear(); foreach (var p in Palette) { var b = Button(p == color ? "✓" : "", () => { color = p; DrawColors(); }, p); b.Foreground = Brush("#10131E"); b.Width = 49; b.Height = 35; b.Padding = new Thickness(0); b.Margin = new Thickness(0, 0, 10, 0); b.ToolTip = p; colors.Children.Add(b); } } DrawColors();
string? customIcon = original?.Icon;
var iconRow = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0,16,0,0) };
var iconPreview = new Image { Width = 32, Height = 32, Margin = new Thickness(0,0,10,0) };
var iconStatus = new TextBlock { FontSize = 11, Foreground = Brush("#7B8377"), Margin = new Thickness(0,6,0,0) };
void ShowIcon() { iconPreview.Source = customIcon == null ? null : CustomIcons.Decode(customIcon); iconStatus.Text = customIcon == null ? "Automatic website icon" : "Custom icon included in exports"; }
iconRow.Children.Add(iconPreview);
iconRow.Children.Add(Button("Choose icon", () => {
var picker = new Microsoft.Win32.OpenFileDialog { Title = "Choose a website icon", Filter = "Images|*.png;*.jpg;*.jpeg;*.ico;*.bmp" };
if (picker.ShowDialog(dialog) != true) return;
try { customIcon = CustomIcons.FromFile(picker.FileName); ShowIcon(); }
catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Could not load icon"); }
}));
var automatic = Button("Use website icon", () => { customIcon = null; ShowIcon(); }); automatic.Margin = new Thickness(8,0,0,0); iconRow.Children.Add(automatic);
panel.Children.Add(iconRow); panel.Children.Add(iconStatus); ShowIcon();
var error = new TextBlock { Foreground = Brush("#A83C38"), TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 10) }; panel.Children.Add(error);
var row = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(row);
var save = Button("Save website", () => {
if (string.IsNullOrWhiteSpace(name.Text) || !ValidUrl(url.Text, out var normalized)) { error.Text = "Enter a name and a valid HTTP or HTTPS website."; return; }
var next = entries.ToList(); var item = new Shortcut(name.Text.Trim(), normalized, string.IsNullOrWhiteSpace(group.Text) ? "Everyday" : group.Text.Trim(), color, customIcon, original?.Id ?? Guid.NewGuid().ToString("N"));
if (original == null) next.Add(item); else next[next.FindIndex(x => x.Id == original.Id)] = item;
if (Save(next)) dialog.Close();
}, "#294B3D"); save.Foreground = Brushes.White; save.IsDefault = true; row.Children.Add(save);
var cancel = Button("Cancel", dialog.Close); cancel.IsCancel = true; cancel.Margin = new Thickness(8, 0, 0, 0); row.Children.Add(cancel);
if (original != null) { var remove = Button("Delete", () => { if (MessageBox.Show(dialog, $"Remove {original.Name}?", "Delete website", MessageBoxButton.YesNo) == MessageBoxResult.Yes) { if (DeleteShortcut(original)) dialog.Close(); } }, "#F8E9E5"); remove.Margin = new Thickness(8, 0, 0, 0); row.Children.Add(remove); }
dialog.ContentRendered += (_, _) => { dialog.Activate(); name.Focus(); Keyboard.Focus(name); }; dialog.ShowDialog();
}
}