commit 79ae6bd7f29a05cd99049fcb7d125473e31c3419 Author: Dario P. Date: Thu Oct 15 22:46:21 2026 +0200 Initial release of Orbit 1.1.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fd9c3a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +bin/ +obj/ +portable/ +release/ +*.zip +.vs/ +*.user +settings.json* +shortcuts.json* diff --git a/App.xaml b/App.xaml new file mode 100644 index 0000000..d9655c5 --- /dev/null +++ b/App.xaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/App.xaml.cs b/App.xaml.cs new file mode 100644 index 0000000..e3f4ffc --- /dev/null +++ b/App.xaml.cs @@ -0,0 +1,10 @@ +using System.Windows; +namespace ShortcutsPanel; +public partial class App : Application +{ + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + new MainWindow().Show(); + } +} diff --git a/CustomIcons.cs b/CustomIcons.cs new file mode 100644 index 0000000..dc075bf --- /dev/null +++ b/CustomIcons.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Windows.Media.Imaging; + +namespace ShortcutsPanel; + +public static class CustomIcons +{ + public static string FromFile(string path) + { + if (new FileInfo(path).Length > 5 * 1024 * 1024) throw new InvalidDataException("Choose an image smaller than 5 MB."); + using var stream = File.OpenRead(path); + var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.DecodePixelWidth = 96; bitmap.StreamSource = stream; bitmap.EndInit(); bitmap.Freeze(); + var png = new PngBitmapEncoder(); png.Frames.Add(BitmapFrame.Create(bitmap)); using var output = new MemoryStream(); png.Save(output); + var encoded = Convert.ToBase64String(output.ToArray()); Decode(encoded); return encoded; + } + public static BitmapSource Decode(string encoded) + { + if (encoded.Length > 350000) throw new InvalidDataException("Custom icon is too large."); + using var stream = new MemoryStream(Convert.FromBase64String(encoded)); + var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.DecodePixelWidth = 96; bitmap.StreamSource = stream; bitmap.EndInit(); bitmap.Freeze(); + if (bitmap.PixelHeight > 1024) throw new InvalidDataException("Custom icon has unsupported dimensions."); + return bitmap; + } +} diff --git a/LibraryOperations.cs b/LibraryOperations.cs new file mode 100644 index 0000000..e0e3b90 --- /dev/null +++ b/LibraryOperations.cs @@ -0,0 +1,64 @@ +using System.IO; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ShortcutsPanel; + +public sealed record LibraryExport(int Version, List Links, List Collections); + +public static class LibraryOperations +{ + public static List Validate(IEnumerable input) + { + var result = new List(); var ids = new HashSet(); + foreach (var item in input) + { + if (result.Count >= 5000) throw new InvalidDataException("A library can contain at most 5,000 websites."); + if (item == null || string.IsNullOrWhiteSpace(item.Name) || item.Name.Length > 60 || string.IsNullOrWhiteSpace(item.Group) || item.Group.Length > 60 || item.Url?.Length > 2048 || !MainWindow.ValidUrl(item.Url, out var url)) throw new InvalidDataException("Every website needs a name, collection, and valid HTTP or HTTPS URL."); + if (item.Color == null || !Regex.IsMatch(item.Color, "^#[0-9a-fA-F]{6}$")) throw new InvalidDataException("Invalid website accent color."); + if (item.Icon != null) CustomIcons.Decode(item.Icon); + var id = item.Id; + if (string.IsNullOrWhiteSpace(id) || id.Length > 64 || !ids.Add(id)) { id = Guid.NewGuid().ToString("N"); ids.Add(id); } + result.Add(item with { Id = id, Name = item.Name.Trim(), Url = url, Group = item.Group.Trim() == "All" ? "All (collection)" : item.Group.Trim() }); + } + return result; + } + public static List Collections(IEnumerable entries, IEnumerable order) => + order.Concat(entries.Select(x => x.Group)).Where(x => !string.IsNullOrWhiteSpace(x) && x != "All" && x.Length <= 60).Select(x => x.Trim()).Distinct().ToList(); + + public static List MoveBefore(IEnumerable source, T moving, T target) + { + var list = source.ToList(); + if (EqualityComparer.Default.Equals(moving, target) || !list.Contains(moving) || !list.Contains(target)) return list; + list.Remove(moving); list.Insert(list.IndexOf(target), moving); return list; + } + public static List MoveAfter(IEnumerable source, T moving, T target) + { + var list = source.ToList(); + if (EqualityComparer.Default.Equals(moving, target) || !list.Contains(moving) || !list.Contains(target)) return list; + list.Remove(moving); list.Insert(list.IndexOf(target) + 1, moving); return list; + } + public static LibraryExport ParseExport(string json) + { + if (json.Length > 20 * 1024 * 1024) throw new InvalidDataException("Import is too large (20 MB maximum)."); + using var document = JsonDocument.Parse(json); + List? links; List order = []; + if (document.RootElement.ValueKind == JsonValueKind.Array) links = document.RootElement.Deserialize>(); + else + { + var exported = document.RootElement.Deserialize() ?? throw new InvalidDataException("Invalid library file."); + if (exported.Version != 1) throw new InvalidDataException("This library version is not supported."); + links = exported.Links; order = exported.Collections ?? []; + } + if (links == null) throw new InvalidDataException("No websites found in the import."); + var valid = Validate(links); return new(1, valid, Collections(valid, order)); + } + public static List Merge(IEnumerable existing, IEnumerable incoming) + { + var result = existing.ToList(); + foreach (var item in incoming) + if (!result.Any(x => x.Url == item.Url && x.Name.Equals(item.Name, StringComparison.OrdinalIgnoreCase) && x.Group.Equals(item.Group, StringComparison.OrdinalIgnoreCase))) + result.Add(item with { Id = Guid.NewGuid().ToString("N") }); + return Validate(result); + } +} diff --git a/MainWindow.Features.cs b/MainWindow.Features.cs new file mode 100644 index 0000000..6a7114f --- /dev/null +++ b/MainWindow.Features.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; + +namespace ShortcutsPanel; + +public partial class MainWindow +{ + static Button SidebarButton(string title, string glyph, Action action) + { + var button = Button("", action, "#F1F2ED"); + button.Padding = new Thickness(12,10,12,10); button.Margin = new Thickness(0,2,0,2); + var row = new StackPanel { Orientation = Orientation.Horizontal, Width = 138 }; + row.Children.Add(new TextBlock { Text = glyph, FontFamily = new FontFamily("Segoe MDL2 Assets"), FontSize = 14, Width = 24, VerticalAlignment = VerticalAlignment.Center, Foreground = Brush("#7B8377") }); + row.Children.Add(new TextBlock { Text = title, FontSize = 12, VerticalAlignment = VerticalAlignment.Center }); + button.Content = row; + System.Windows.Automation.AutomationProperties.SetName(button, title); + return button; + } + Window Dialog(string title, out StackPanel panel, double height = 420) + { + var window = new Window { Owner = this, Title = title, Width = 500, Height = height, MinHeight = 300, MinWidth = 420, WindowStartupLocation = WindowStartupLocation.CenterOwner, ShowInTaskbar = true }; + panel = new StackPanel { Margin = new Thickness(28) }; + window.Content = new ScrollViewer { Content = panel, VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; + panel.Children.Add(new TextBlock { Text = title, FontSize = 26, FontWeight = FontWeights.SemiBold, Margin = new Thickness(0,0,0,18) }); + return window; + } + void About() + { + var dialog = Dialog("About Orbit", out var panel, 380); + var title = (TextBlock)panel.Children[0]; panel.Children.RemoveAt(0); + title.Margin = new Thickness(0); title.VerticalAlignment = VerticalAlignment.Center; + var header = new DockPanel { Margin = new Thickness(0,0,0,20) }; + var logo = new Image { Source = LoadAppIcon(), Width = 48, Height = 48, Margin = new Thickness(16,0,0,0) }; + DockPanel.SetDock(logo, Dock.Right); header.Children.Add(logo); header.Children.Add(title); panel.Children.Insert(0, header); + var version = Assembly.GetExecutingAssembly().GetName().Version; + panel.Children.Add(new TextBlock { Text = $"Version {version?.Major}.{version?.Minor}.{version?.Build}", FontSize = 14, Margin = new Thickness(0,0,0,20) }); + panel.Children.Add(new TextBlock { Text = "Created by Dario P. • 2026", FontSize = 19, FontWeight = FontWeights.SemiBold }); + panel.Children.Add(new TextBlock { Text = "Your favorite websites, organized in one place.\nA portable Windows launcher with collections, custom icons, and light and dark themes.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,16,0,24), Foreground = Brush("#7B8377") }); + panel.Children.Add(Button("Close", dialog.Close)); dialog.ShowDialog(); + } + public static System.Windows.Media.Imaging.BitmapSource LoadAppIcon() + { + using var stream = typeof(MainWindow).Assembly.GetManifestResourceStream("OrbitIcon") ?? throw new IOException("Missing embedded icon."); + var decoder = System.Windows.Media.Imaging.BitmapDecoder.Create(stream, System.Windows.Media.Imaging.BitmapCreateOptions.None, System.Windows.Media.Imaging.BitmapCacheOption.OnLoad); + var frame = decoder.Frames[0]; frame.Freeze(); return frame; + } + void Transfer() + { + var dialog = Dialog("Import / Export", out var panel, 430); + panel.Children.Add(new TextBlock { Text = "Share your websites, collections, order, and custom icons in a single JSON file. Your theme stays personal to this PC.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,0,0,20) }); + panel.Children.Add(Button("Export library…", () => { + if (!readable) { MessageBox.Show(dialog, "Resolve the startup library error first."); return; } + var picker = new Microsoft.Win32.SaveFileDialog { Title = "Export Orbit library", FileName = "Orbit-library.json", Filter = "Orbit library|*.json", DefaultExt = ".json" }; + if (picker.ShowDialog(dialog) != true) return; + try { File.WriteAllText(picker.FileName, JsonSerializer.Serialize(new LibraryExport(1, entries, collectionOrder), new JsonSerializerOptions { WriteIndented = true })); dialog.Close(); } + catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Export failed"); } + })); + var import = Button("Import library…", () => { + var picker = new Microsoft.Win32.OpenFileDialog { Title = "Import Orbit library", Filter = "Orbit library|*.json" }; + if (picker.ShowDialog(dialog) != true) return; + try + { + if (new FileInfo(picker.FileName).Length > 20 * 1024 * 1024) throw new InvalidDataException("Choose a library smaller than 20 MB."); + var incoming = LibraryOperations.ParseExport(File.ReadAllText(picker.FileName)); + var choice = MessageBox.Show(dialog, $"Import {incoming.Links.Count} websites in {incoming.Collections.Count} collections?\n\nYes: Merge with your library (skip matching name, URL, and collection).\nNo: Replace your library.\nCancel: Make no changes.", "Choose import mode", MessageBoxButton.YesNoCancel, MessageBoxImage.Question, MessageBoxResult.Cancel); + if (choice == MessageBoxResult.Cancel) return; + var next = choice == MessageBoxResult.Yes ? LibraryOperations.Merge(entries, incoming.Links) : incoming.Links; + var order = choice == MessageBoxResult.Yes ? collectionOrder.Concat(incoming.Collections).Distinct().ToList() : incoming.Collections; + if (Save(next, order)) { deleted.Clear(); if (undoButton != null) undoButton.IsEnabled = false; dialog.Close(); } + } + catch (Exception ex) { MessageBox.Show(dialog, ex.Message, "Import failed — library unchanged"); } + }); import.Margin = new Thickness(0,12,0,18); panel.Children.Add(import); + panel.Children.Add(Button("Cancel", dialog.Close)); dialog.ShowDialog(); + } + bool DeleteShortcut(Shortcut item) + { + var index = entries.FindIndex(x => x.Id == item.Id); if (index < 0) return false; + var next = entries.ToList(); next.RemoveAt(index); + if (!Save(next)) return false; + deleted.Push((item, index)); if (undoButton != null) undoButton.IsEnabled = true; + status.Text = $"Deleted {item.Name} · Undo is available"; return true; + } + void UndoDelete() + { + if (!deleted.TryPeek(out var restore)) return; + var next = entries.ToList(); next.Insert(Math.Min(restore.Index, next.Count), restore.Item); + if (Save(next)) { deleted.Pop(); if (undoButton != null) undoButton.IsEnabled = deleted.Count > 0; } + } + void ManageCollections() + { + var dialog = Dialog("Collections", out var panel, 540); + panel.Children.Add(new TextBlock { Text = "Select a collection to rename, open, or remove.\nDrag collections in the sidebar to reorder them.", TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0,0,0,14) }); + var list = new ComboBox { ItemsSource = collectionOrder.ToList(), SelectedItem = selectedGroup != "All" ? selectedGroup : collectionOrder.FirstOrDefault(), MinHeight = 34, Foreground = Brushes.Black }; panel.Children.Add(list); + var name = new TextBox { MaxLength = 60, Margin = new Thickness(0,14,0,12), Text = list.SelectedItem as string ?? "" }; panel.Children.Add(name); + list.SelectionChanged += (_, _) => name.Text = list.SelectedItem as string ?? ""; + var result = new TextBlock { TextWrapping = TextWrapping.Wrap, Foreground = Brush("#A83C38"), Margin = new Thickness(0,8,0,8) }; + var row = new StackPanel { Orientation = Orientation.Horizontal }; panel.Children.Add(row); + row.Children.Add(Button("Rename", () => { + if (list.SelectedItem is not string old) return; + var value = name.Text.Trim(); + if (value.Length == 0 || value == "All") { result.Text = "Choose a collection name other than All."; return; } + if (collectionOrder.Any(x => x != old && x.Equals(value, StringComparison.OrdinalIgnoreCase))) { result.Text = "That collection already exists."; return; } + if (Save(entries.Select(x => x.Group == old ? x with { Group = value } : x).ToList(), collectionOrder.Select(x => x == old ? value : x).ToList())) { selectedGroup = value; Render(); dialog.Close(); } + })); + var create = Button("New collection", () => { + var value = name.Text.Trim(); + if (value.Length == 0 || value == "All" || collectionOrder.Any(x => x.Equals(value, StringComparison.OrdinalIgnoreCase))) { result.Text = "Enter a new, unique name other than All."; return; } + if (Save(entries, collectionOrder.Append(value).ToList())) { selectedGroup = value; Render(); dialog.Close(); } + }); create.Margin = new Thickness(10,0,0,0); row.Children.Add(create); + var open = Button("Open collection", () => { if (list.SelectedItem is string group) OpenCollection(group); }); open.Margin = new Thickness(0,12,0,10); panel.Children.Add(open); + panel.Children.Add(Button("Delete collection", () => { + if (list.SelectedItem is not string old) return; + var destination = old == "Unsorted" ? "Everyday" : "Unsorted"; + if (MessageBox.Show(dialog, $"Delete {old}? Its websites will be kept in {destination}.", "Delete collection", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes) return; + if (Save(entries.Select(x => x.Group == old ? x with { Group = destination } : x).ToList(), collectionOrder.Where(x => x != old).ToList())) dialog.Close(); + }, "#F8E9E5")); panel.Children.Add(result); panel.Children.Add(Button("Close", dialog.Close)); dialog.ShowDialog(); + } + void OpenCollection(string group) + { + var links = entries.Where(x => x.Group == group).Select(x => x.Url).Distinct().ToList(); + if (links.Count == 0) { MessageBox.Show(this, "This collection has no websites yet."); return; } + if (links.Count > 1 && MessageBox.Show(this, $"Open {links.Count} websites from {group} in your default browser?", "Open collection", MessageBoxButton.YesNo, MessageBoxImage.Question, MessageBoxResult.No) != MessageBoxResult.Yes) return; + var errors = new List(); + foreach (var url in links) { try { Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } catch (Exception ex) { errors.Add(ex.Message); } } + if (errors.Count > 0) MessageBox.Show(this, $"{errors.Count} websites could not be opened.\n" + errors[0], "Open collection"); + } + static void DragSource(FrameworkElement source, string format, string value, Action? dragState = null) + { + Point? start = null; + source.PreviewMouseLeftButtonDown += (_, e) => start = e.GetPosition(source); + source.PreviewMouseLeftButtonUp += (_, _) => start = null; + source.PreviewMouseMove += (_, e) => { + if (e.LeftButton != MouseButtonState.Pressed || start is not Point origin) return; + var current = e.GetPosition(source); + if (Math.Abs(current.X - origin.X) < SystemParameters.MinimumHorizontalDragDistance && Math.Abs(current.Y - origin.Y) < SystemParameters.MinimumVerticalDragDistance) return; + start = null; e.Handled = true; + // Cancel a nested button's pressed state so dragging a card never opens its website. + Mouse.Capture(null); + dragState?.Invoke(true); + try { DragDrop.DoDragDrop(source, new DataObject(format, value), DragDropEffects.Move); } + finally { dragState?.Invoke(false); } + }; + } + void ConfigureCard(Border card, Shortcut item) + { + bool dragging = false, dropTarget = false; + void UpdateOutline() + { + var highlighted = dragging || dropTarget || card.IsKeyboardFocusWithin; + card.BorderBrush = highlighted ? Brush("#56B879") : Brush(card.IsMouseOver ? "#ACBDA4" : "#E5E8E0"); + card.BorderThickness = new Thickness(highlighted ? 2 : 1); + card.Padding = highlighted ? new Thickness(19,17,19,13) : new Thickness(20,18,20,14); + card.Opacity = dragging ? 0.65 : 1; + } + card.MouseEnter += (_, _) => UpdateOutline(); card.MouseLeave += (_, _) => UpdateOutline(); + card.IsKeyboardFocusWithinChanged += (_, _) => UpdateOutline(); + DragSource(card, "Orbit.Website", item.Id, active => { dragging = active; UpdateOutline(); }); card.AllowDrop = true; + card.DragEnter += (_, e) => { dropTarget = e.Data.GetDataPresent("Orbit.Website"); UpdateOutline(); }; + card.DragLeave += (_, _) => { dropTarget = false; UpdateOutline(); }; + card.DragOver += (_, e) => { e.Effects = e.Data.GetDataPresent("Orbit.Website") ? DragDropEffects.Move : DragDropEffects.None; e.Handled = true; }; + card.Drop += (_, e) => { + dropTarget = false; UpdateOutline(); + if (e.Data.GetData("Orbit.Website") is string id && entries.FirstOrDefault(x => x.Id == id) is Shortcut moving) + Save(e.GetPosition(card).Y > card.ActualHeight / 2 ? LibraryOperations.MoveAfter(entries, moving, item) : LibraryOperations.MoveBefore(entries, moving, item)); + e.Handled = true; + }; + var menu = new ContextMenu(); + foreach (var label in new[] { "Move earlier", "Move later" }) + { + var option = new MenuItem { Header = label }; option.Click += (_, _) => { + var index = entries.FindIndex(x => x.Id == item.Id); var destination = index + (label == "Move earlier" ? -1 : 1); + if (index < 0 || destination < 0 || destination >= entries.Count) return; + var next = entries.ToList(); (next[index], next[destination]) = (next[destination], next[index]); Save(next); + }; menu.Items.Add(option); + } + card.ContextMenu = menu; + } + void ConfigureCollection(Button button, string group) + { + button.ToolTip = "Drag to reorder • Right-click for collection actions"; + DragSource(button, "Orbit.Collection", group); button.AllowDrop = true; + button.DragOver += (_, e) => { e.Effects = e.Data.GetDataPresent("Orbit.Collection") ? DragDropEffects.Move : DragDropEffects.None; e.Handled = true; }; + button.Drop += (_, e) => { + if (e.Data.GetData("Orbit.Collection") is string moving) + Save(entries, e.GetPosition(button).Y > button.ActualHeight / 2 ? LibraryOperations.MoveAfter(collectionOrder, moving, group) : LibraryOperations.MoveBefore(collectionOrder, moving, group)); + e.Handled = true; + }; + var menu = new ContextMenu(); var open = new MenuItem { Header = "Open collection" }; open.Click += (_, _) => OpenCollection(group); menu.Items.Add(open); + var manage = new MenuItem { Header = "Manage collection" }; manage.Click += (_, _) => { selectedGroup = group; ManageCollections(); }; menu.Items.Add(manage); + foreach (var label in new[] { "Move earlier", "Move later" }) + { + var option = new MenuItem { Header = label }; option.Click += (_, _) => { + var index = collectionOrder.IndexOf(group); var destination = index + (label == "Move earlier" ? -1 : 1); + if (index < 0 || destination < 0 || destination >= collectionOrder.Count) return; + var next = collectionOrder.ToList(); (next[index], next[destination]) = (next[destination], next[index]); Save(entries, next); + }; menu.Items.Add(option); + } + button.ContextMenu = menu; + } +} diff --git a/MainWindow.cs b/MainWindow.cs new file mode 100644 index 0000000..fa0282e --- /dev/null +++ b/MainWindow.cs @@ -0,0 +1,221 @@ +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> 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 entries = []; + string selectedGroup = "All"; + List 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>(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 updated, List? 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(); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d17912 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# Orbit + +A portable C# / WPF website launcher for Windows, created by **Dario P.** + +## Features + +- Website cards with automatic or custom icons +- Collections, search, and drag-to-reorder +- Import/export libraries with custom icons +- Undo shortcut deletion and open collections together +- Light and charcoal themes +- Starts maximized; green outer outlines indicate dragging and focus + +## Run + +Download the portable Windows x64 package from this repository's Releases page, +extract it, and run `ShortcutsPanel.exe`. No installer or separate .NET runtime +is required. Windows 10 or Windows 11, 64-bit. + +## Settings + +Links and preferences are stored in `%LOCALAPPDATA%\Orbit\settings.json`. +**Open settings** opens that folder. The previous save is retained as +`settings.json.bak`. Older Orbit registry or sidecar settings migrate automatically. + +Use **Import / Export** to share your library. Copying the executable alone does +not carry personal links. Custom icons and collection order are included in exports; +the theme remains local. Undo history lasts for the current session. + +## Build + +Requires Windows and the .NET 8 SDK. + +```powershell +dotnet publish ShortcutsPanel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o release +``` + +## Checks + +```powershell +dotnet run --project tests/StorageChecks.csproj +dotnet run --project tests/FeatureChecks.csproj +dotnet run --project tests/ThemeChecks.csproj +``` + +Theme checks include live icon downloads; unavailable sites use fallback icons. +See [README.txt](README.txt) for detailed controls and release notes. diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..964d286 --- /dev/null +++ b/README.txt @@ -0,0 +1,56 @@ +ORBIT 1.1.2 - WEBSITE SHORTCUTS +Created by Dario P. + +Run ShortcutsPanel.exe on 64-bit Windows 10 or Windows 11. +No installer or separate .NET installation is required. + +NEW IN 1.1 +- Import / Export: share a JSON library including custom icons and collection + order. Import supports merge (skip matching name, URL, collection) or replace. + Your theme remains personal to this PC. Imports are validated before saving. +- Drag anywhere on a card. Drop in the upper half of a target to place + it before that card, or the lower half to place it after. Drag collection + buttons similarly. Right-click also offers Move earlier / Move later. +- Choose icon in Add/Edit accepts PNG, JPEG, ICO, or BMP under 5 MB. Icons are + resized and stored inside the JSON, so they travel with exported libraries. + Use website icon restores automatic icon detection. +- Manage collections: create, rename, or delete collections. Deleting a + collection preserves its links in Unsorted (or Everyday if deleting Unsorted). +- Undo deletion restores deleted shortcuts while this session remains open. + Importing a library clears undo history. Undo history is not saved on exit. +- Open collection is available in Manage collections and the right-click menu. + Opening multiple sites asks for confirmation and opens each distinct URL once. +- About Orbit displays version 1.1.2 and author Dario P. + +Add, edit, delete, search, and group website cards. Dark mode is optional. +Website icons are discovered from page metadata, then /favicon.ico. +Unavailable icons use a letter fallback. + +Links, icons, card colors, collection order, and theme preference are saved in: +%LOCALAPPDATA%\Orbit\settings.json +Use Open settings in the sidebar to open this folder in Explorer. +The previous save is kept as settings.json.bak. +This is persistent per-user app storage, not a temporary folder. + +Existing Orbit registry settings or a legacy shortcuts.json beside the EXE +are migrated on first use if the new JSON settings file is absent. +Legacy data is preserved. Future changes use the JSON file only. + +Use Import / Export to share your library. Copying only the EXE does not include +personal changes. Exports contain website addresses and any custom icons; +review your library before sharing it. The theme is not included in exports. + +The bundled .NET runtime extracts supporting files to temporary storage. +This executable is not digitally signed. + +Build: +dotnet publish ShortcutsPanel.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -p:DebugSymbols=false -o release + +Checks: +dotnet run --project tests/StorageChecks.csproj +dotnet run --project tests/FeatureChecks.csproj +dotnet run --project tests/ThemeChecks.csproj + +1.1.1: aligned sidebar rows, whole-card dragging, and the Orbit icon in About. + +1.1.2: starts maximized; green outer borders for drag/focus; Dark mode is at the sidebar bottom; About credits Dario P. and 2026. diff --git a/SettingsStore.cs b/SettingsStore.cs new file mode 100644 index 0000000..5fbe571 --- /dev/null +++ b/SettingsStore.cs @@ -0,0 +1,79 @@ +using System.IO; +using System.Text.Json; +using Microsoft.Win32; + +namespace ShortcutsPanel; + +public sealed class SettingsStore +{ + readonly string registryPath; + readonly string legacyPath; + public string Location { get; } + public string SettingsPath => Path.Combine(Location, "settings.json"); + public sealed class Data + { + public int Version { get; set; } = 1; + public JsonElement? Links { get; set; } + public bool DarkTheme { get; set; } + public List Collections { get; set; } = []; + } + public SettingsStore(string registryPath = @"Software\OrbitShortcutsPanel", string? legacyPath = null, string? directory = null) + { + this.registryPath = registryPath; + this.legacyPath = legacyPath ?? Path.Combine(AppContext.BaseDirectory, "shortcuts.json"); + Location = directory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Orbit"); + } + public void OpenLocation() + { + Directory.CreateDirectory(Location); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("explorer.exe", "\"" + Location + "\"") { UseShellExecute = true }); + } + Data Read() + { + if (File.Exists(SettingsPath)) + { + var data = JsonSerializer.Deserialize(File.ReadAllText(SettingsPath)) ?? throw new InvalidDataException("Empty settings file."); + if (data.Version != 1) throw new InvalidDataException("Unsupported settings version."); + if (data.Links is JsonElement links && links.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Invalid links in settings."); + return data; + } + var migrated = new Data(); + // Only read the registry for migration; new changes are always saved to JSON. + using var key = Registry.CurrentUser.OpenSubKey(registryPath); + var previous = key?.GetValue("Library"); + if (previous != null && previous is not string) throw new InvalidDataException("Invalid legacy library type."); + string? json = previous as string; + if (json == null && File.Exists(legacyPath)) json = File.ReadAllText(legacyPath); + if (json != null) migrated.Links = ParseLinks(json); + migrated.DarkTheme = key?.GetValue("DarkTheme") is int value && value == 1; + if (json != null || key?.GetValue("DarkTheme") != null) Write(migrated); + return migrated; + } + static JsonElement ParseLinks(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Links must be a JSON array."); + return document.RootElement.Clone(); + } + void Write(Data data) + { + Directory.CreateDirectory(Location); + var temporary = Path.Combine(Location, "settings-" + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true })); + if (File.Exists(SettingsPath)) File.Replace(temporary, SettingsPath, SettingsPath + ".bak"); + else File.Move(temporary, SettingsPath); + } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } + public string? Load() => Read().Links?.GetRawText(); + public bool LoadDarkTheme() => Read().DarkTheme; + public List LoadCollections() => Read().Collections ?? []; + public void SaveLibrary(string json, List collections) + { + var data = Read(); data.Links = ParseLinks(json); data.Collections = collections; Write(data); + } + public void SaveDarkTheme(bool dark) { var data = Read(); data.DarkTheme = dark; Write(data); } + public void Save(string json) { var data = Read(); data.Links = ParseLinks(json); Write(data); } +} diff --git a/ShortcutsPanel.csproj b/ShortcutsPanel.csproj new file mode 100644 index 0000000..6e6915d --- /dev/null +++ b/ShortcutsPanel.csproj @@ -0,0 +1,14 @@ + + + WinExe + net8.0-windows + true + enable + enable + app.ico + ShortcutsPanel + 1.1.2 + + + + diff --git a/Theme.cs b/Theme.cs new file mode 100644 index 0000000..74ccd2b --- /dev/null +++ b/Theme.cs @@ -0,0 +1,64 @@ +using System.Windows; +using System.Windows.Media; +using System.Windows.Data; +using System.ComponentModel; + +namespace ShortcutsPanel; + +public static class Theme +{ + static readonly Dictionary DarkColors = new(StringComparer.OrdinalIgnoreCase) + { + ["#FAF9F6"] = "#202020", ["#252924"] = "#EEEEEE", + ["#F1F2ED"] = "#252525", ["#E2E5DD"] = "#3B3B3B", + ["#294B3D"] = "#505050", ["#294B3C"] = "#E0E0E0", ["#80877D"] = "#AAAAAA", + ["#8C9388"] = "#AAAAAA", ["#838B7F"] = "#AAAAAA", + ["#7B8377"] = "#BBBBBB", ["#868D80"] = "#AAAAAA", + ["#DFE6DA"] = "#3D3D3D", ["#374632"] = "#E5E5E5", + ["#FFFFFF"] = "#2B2B2B", ["#A2AA9B"] = "#AAAAAA", + ["#F5F6F2"] = "#EAEAEA", ["#252D23"] = "#EEEEEE", + ["#8B9285"] = "#AAAAAA", ["#E5E8E0"] = "#414141", + ["#ACBDA4"] = "#777777", ["#EEF0E9"] = "#3A3A3A", + ["#DDE2D6"] = "#555555", ["#A83C38"] = "#FFB3AB", + ["#F8E9E5"] = "#583936", ["#53654B"] = "#505050", + ["#738B65"] = "#999999", ["#528462"] = "#777777", + ["#AACB94"] = "#BBBBBB", ["#8A9485"] = "#666666" + }; + static readonly Dictionary Brushes = new(StringComparer.OrdinalIgnoreCase); + static readonly Dictionary Colors = new(StringComparer.OrdinalIgnoreCase); + sealed class ThemeColor : INotifyPropertyChanged + { + public Color Color { get; private set; } + public event PropertyChangedEventHandler? PropertyChanged; + public void Set(Color color) { Color = color; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Color))); } + } + public static bool IsDark { get; private set; } + public static SolidColorBrush Brush(string light) + { + if (!Brushes.TryGetValue(light, out var brush)) + { + var value = new ThemeColor(); value.Set(ColorFor(light)); Colors[light] = value; + Brushes[light] = brush = new SolidColorBrush(); + // A binding keeps WPF styles from freezing a brush that must change at runtime. + BindingOperations.SetBinding(brush, SolidColorBrush.ColorProperty, new Binding(nameof(ThemeColor.Color)) { Source = value }); + } + return brush; + } + static Color ColorFor(string light) => (Color)ColorConverter.ConvertFromString( + IsDark && DarkColors.TryGetValue(light, out var dark) ? dark : light); + public static void Apply(bool dark) + { + IsDark = dark; + foreach (var pair in Colors) pair.Value.Set(ColorFor(pair.Key)); + var resources = Application.Current.Resources; + resources["WindowBackground"] = Brush("#FAF9F6"); + resources["Ink"] = Brush("#252924"); + resources["ControlBackground"] = Brush("#EEF0E9"); + resources["InputBackground"] = Brush("#FFFFFF"); + resources["InputBorder"] = Brush("#DDE2D6"); + resources["FocusBorder"] = Brush("#738B65"); + resources["SwitchOn"] = Brush("#528462"); + resources["SwitchOff"] = Brush("#8A9485"); + resources["SwitchFocus"] = Brush("#AACB94"); + } +} diff --git a/WebsiteIcons.cs b/WebsiteIcons.cs new file mode 100644 index 0000000..5922df1 --- /dev/null +++ b/WebsiteIcons.cs @@ -0,0 +1,93 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Windows.Media.Imaging; + +namespace ShortcutsPanel; + +public static class WebsiteIcons +{ + static readonly HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(12) }; + static readonly SemaphoreSlim Slots = new(4); + static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(200); + + // Read declared favicons and touch icons, including relative and CDN URLs. + public static IEnumerable Discover(string html, Uri page) + { + var baseTag = Regex.Match(html, @"]*>", RegexOptions.IgnoreCase, RegexTimeout); + var baseAttrs = Attributes(baseTag.Value); + if (baseAttrs.TryGetValue("href", out var baseHref) && SafeUri(page, baseHref) is Uri resolvedBase) page = resolvedBase; + var found = new List<(Uri Uri, int Rank)>(); + foreach (Match tag in Regex.Matches(html, @"]*>", RegexOptions.IgnoreCase, RegexTimeout)) + { + var attrs = Attributes(tag.Value); + if (!attrs.TryGetValue("rel", out var rel) || !attrs.TryGetValue("href", out var href)) continue; + var tokens = rel.ToLowerInvariant().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (!tokens.Contains("icon") && !tokens.Contains("apple-touch-icon") && !tokens.Contains("apple-touch-icon-precomposed")) continue; + var uri = SafeUri(page, href); + if (uri == null || uri.AbsolutePath.EndsWith(".svg", StringComparison.OrdinalIgnoreCase) || attrs.GetValueOrDefault("type", "").Contains("svg", StringComparison.OrdinalIgnoreCase)) continue; + found.Add((uri, tokens.Contains("apple-touch-icon") ? 0 : 1)); + } + return found.OrderBy(x => x.Rank).Select(x => x.Uri).Distinct().Take(6).ToArray(); + } + static Dictionary Attributes(string tag) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match match in Regex.Matches(tag, "([\\w:-]+)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))", RegexOptions.None, RegexTimeout)) + result[match.Groups[1].Value] = WebUtility.HtmlDecode(match.Groups[2].Success ? match.Groups[2].Value : match.Groups[3].Success ? match.Groups[3].Value : match.Groups[4].Value); + return result; + } + static Uri? SafeUri(Uri page, string href) => Uri.TryCreate(page, href, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https") && string.IsNullOrEmpty(uri.UserInfo) ? uri : null; + static async Task<(byte[] Bytes, Uri FinalUri)> Download(Uri uri, int limit, CancellationToken token) + { + using var response = await Client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, token); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength > limit) throw new IOException("Icon response too large."); + using var stream = await response.Content.ReadAsStreamAsync(token); + using var output = new MemoryStream(); var buffer = new byte[8192]; int count; + while ((count = await stream.ReadAsync(buffer, token)) > 0) + { + if (output.Length + count > limit) throw new IOException("Icon response too large."); + output.Write(buffer, 0, count); + } + return (output.ToArray(), response.RequestMessage?.RequestUri ?? uri); + } + public static async Task Fetch(string address) + { + await Slots.WaitAsync(); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var page = new Uri(address); var candidates = new List(); + try + { + using var pageTimeout = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + pageTimeout.CancelAfter(TimeSpan.FromSeconds(5)); + var document = await Download(page, 1024 * 1024, pageTimeout.Token); + page = document.FinalUri; + candidates.AddRange(Discover(Encoding.UTF8.GetString(document.Bytes), page)); + } + catch { /* Try the conventional favicon when HTML discovery is unavailable. */ } + candidates.Add(new Uri(page.GetLeftPart(UriPartial.Authority) + "/favicon.ico")); + foreach (var candidate in candidates.Distinct()) + { + if (timeout.IsCancellationRequested) break; + try + { + using var iconTimeout = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token); + iconTimeout.CancelAfter(TimeSpan.FromSeconds(3)); + var data = await Download(candidate, 512 * 1024, iconTimeout.Token); + using var bytes = new MemoryStream(data.Bytes); + var bitmap = new BitmapImage(); bitmap.BeginInit(); bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.DecodePixelWidth = 32; bitmap.StreamSource = bytes; bitmap.EndInit(); bitmap.Freeze(); return bitmap; + } + catch { /* A missing or unsupported icon must not prevent using a shortcut. */ } + } + return null; + } + catch { return null; } + finally { Slots.Release(); } + } +} diff --git a/app.ico b/app.ico new file mode 100644 index 0000000..f64dbe8 Binary files /dev/null and b/app.ico differ diff --git a/tests/FeatureChecks.cs b/tests/FeatureChecks.cs new file mode 100644 index 0000000..e550c96 --- /dev/null +++ b/tests/FeatureChecks.cs @@ -0,0 +1,61 @@ +using System.IO; +using System.Reflection; +using System.Text.Json; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using ShortcutsPanel; + +internal static class FeatureChecks +{ + static void Check(bool condition, string name) { if (!condition) throw new Exception(name); Console.WriteLine("PASS " + name); } + [STAThread] + static void Main() + { + var directory = Path.Combine(Path.GetTempPath(), "OrbitFeatures-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + var store = new SettingsStore(@"Software\OrbitTests-" + Guid.NewGuid().ToString("N"), Path.Combine(directory, "absent.json"), directory); + var app = new App(); app.InitializeComponent(); + Check(MainWindow.LoadAppIcon().PixelWidth > 0, "About icon loads from embedded resource"); + var window = new MainWindow(store); + object? Call(string method, params object?[] args) => typeof(MainWindow).GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(window, args); + try + { + var original = LibraryOperations.Validate([new("A", "https://a.example", "One", "#A697FF"), new("B", "https://b.example", "Two", "#60D6B2"), new("C", "https://c.example", "One", "#60D6B2")]); + Check(original.Select(x => x.Id).Distinct().Count() == 3, "Legacy links receive unique identities"); + var moved = LibraryOperations.MoveBefore(original, original[2], original[0]); + Check(moved.Select(x => x.Name).SequenceEqual(new[] { "C", "A", "B" }), "Card reorder preserves all entries"); + Check(LibraryOperations.MoveAfter(original, original[0], original[2]).Select(x => x.Name).SequenceEqual(new[] { "B", "C", "A" }), "Dragging after last card reaches final position"); + var order = LibraryOperations.MoveBefore(new[] { "One", "Two", "Empty" }, "Two", "One"); + Check((bool)Call("Save", moved, order)!, "Save reordered library"); + Check(store.LoadCollections().SequenceEqual(order), "Collection order and empty collection persist"); + Check(JsonSerializer.Deserialize>(store.Load()!)![0].Name == "C", "Card order persists"); + Check((bool)Call("DeleteShortcut", original[0])!, "Delete shortcut"); + Check(JsonSerializer.Deserialize>(store.Load()!)!.Count == 2, "Deletion saved"); + Call("UndoDelete"); + Check(JsonSerializer.Deserialize>(store.Load()!)!.Select(x => x.Name).SequenceEqual(new[] { "C", "A", "B" }), "Undo restores original position"); + var pngPath = Path.Combine(directory, "icon.png"); + var bitmap = BitmapSource.Create(2, 2, 96, 96, PixelFormats.Bgra32, null, new byte[] { 0,0,255,255, 0,255,0,255, 255,0,0,255, 255,255,255,255 }, 8); + var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); using (var file = File.Create(pngPath)) encoder.Save(file); + var icon = CustomIcons.FromFile(pngPath); moved[0] = moved[0] with { Icon = icon }; + var export = new LibraryExport(1, moved, order); + var roundTrip = LibraryOperations.ParseExport(JsonSerializer.Serialize(export)); + Check(roundTrip.Links[0].Icon == icon && CustomIcons.Decode(roundTrip.Links[0].Icon!).PixelWidth > 0, "Custom icon survives export/import"); + Check(roundTrip.Collections.SequenceEqual(order) && roundTrip.Links.Select(x => x.Id).SequenceEqual(moved.Select(x => x.Id)), "Export retains identities and order"); + Check(LibraryOperations.Merge(moved, roundTrip.Links).Count == 3, "Merge skips duplicate shortcuts"); + Check(LibraryOperations.ParseExport(JsonSerializer.Serialize(original)).Links.Count == 3, "Legacy array import supported"); + foreach (var invalid in new[] { "{}", "{\"Version\":99,\"Links\":[]}", "[{\"Name\":\"Unsafe\",\"Url\":\"file:///C:/test\",\"Group\":\"One\",\"Color\":\"#FFFFFF\"}]" }) + { + bool rejected = false; try { LibraryOperations.ParseExport(invalid); } catch { rejected = true; } + Check(rejected, "Invalid import rejected"); + } + Check((bool)Call("Save", roundTrip.Links, roundTrip.Collections)!, "Custom-icon library saves"); + Check(JsonSerializer.Deserialize>(store.Load()!)![0].Icon == icon, "Custom icon persists in JSON"); + } + finally + { + window.Close(); app.Shutdown(); + foreach (var file in Directory.GetFiles(directory)) File.Delete(file); + Directory.Delete(directory); + } + } +} diff --git a/tests/FeatureChecks.csproj b/tests/FeatureChecks.csproj new file mode 100644 index 0000000..a650ee4 --- /dev/null +++ b/tests/FeatureChecks.csproj @@ -0,0 +1,4 @@ + + Exenet8.0-windowstrueenableenablefalse + + diff --git a/tests/Program.cs b/tests/Program.cs new file mode 100644 index 0000000..1469d65 --- /dev/null +++ b/tests/Program.cs @@ -0,0 +1,41 @@ +using Microsoft.Win32; +using ShortcutsPanel; +var key = @"Software\OrbitStorageChecks\" + Guid.NewGuid().ToString("N"); +var folder = Path.Combine(Path.GetTempPath(), "OrbitStorageChecks-" + Guid.NewGuid().ToString("N")); +Directory.CreateDirectory(folder); +var legacy = Path.Combine(folder, "shortcuts.json"); +var dataFolder = Path.Combine(folder, "data"); +void Check(bool condition, string label) { if (!condition) throw new Exception(label); Console.WriteLine("PASS " + label); } +SettingsStore Store() => new(key, legacy, dataFolder); +try +{ + using (var old = Registry.CurrentUser.CreateSubKey(key)) { old.SetValue("Library", "[{\"Name\":\"Original\"}]"); old.SetValue("DarkTheme", 1); } + var store = Store(); + Check(store.LoadDarkTheme(), "Registry theme migrated"); + Check(store.Load()!.Contains("Original"), "Registry links migrated"); + Check(File.Exists(store.SettingsPath), "Migration creates JSON in app data directory"); + using (var old = Registry.CurrentUser.CreateSubKey(key)) old.SetValue("Library", "[]"); + Check(Store().Load()!.Contains("Original"), "JSON takes precedence after migration"); + store.Save("[{\"Name\":\"Edited\"}]"); + Check(Store().Load()!.Contains("Edited"), "Edited links persist"); + Check(Store().LoadDarkTheme(), "Link save preserves theme"); + store.SaveDarkTheme(false); + Check(!Store().LoadDarkTheme() && Store().Load()!.Contains("Edited"), "Theme save preserves links"); + Check(File.Exists(store.SettingsPath + ".bak"), "Previous JSON backed up"); + store.Save("[]"); Check(Store().Load() == "[]", "Empty library persists"); + File.WriteAllText(store.SettingsPath, "broken"); + try { store.Save("[]"); throw new Exception("Corrupt settings overwritten"); } catch (System.Text.Json.JsonException) { } + Check(File.ReadAllText(store.SettingsPath) == "broken", "Corrupt settings preserved"); + File.Delete(store.SettingsPath); + Registry.CurrentUser.DeleteSubKeyTree(key, false); + File.WriteAllText(legacy, "[]"); File.SetAttributes(legacy, FileAttributes.ReadOnly); + Check(Store().Load() == "[]", "Read-only sidecar migration"); + Check(File.ReadAllText(legacy) == "[]", "Legacy file untouched"); +} +finally +{ + Registry.CurrentUser.DeleteSubKeyTree(key, false); + if (File.Exists(legacy)) { File.SetAttributes(legacy, FileAttributes.Normal); File.Delete(legacy); } + if (Directory.Exists(dataFolder)) { foreach (var file in Directory.GetFiles(dataFolder)) File.Delete(file); Directory.Delete(dataFolder); } + Directory.Delete(folder); +} diff --git a/tests/StorageChecks.csproj b/tests/StorageChecks.csproj new file mode 100644 index 0000000..a61662f --- /dev/null +++ b/tests/StorageChecks.csproj @@ -0,0 +1,4 @@ + + Exenet8.0-windowsenableenable + + diff --git a/tests/ThemeChecks.cs b/tests/ThemeChecks.cs new file mode 100644 index 0000000..119269f --- /dev/null +++ b/tests/ThemeChecks.cs @@ -0,0 +1,36 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.IO; +using ShortcutsPanel; + +internal static class ThemeChecks +{ + [STAThread] + static void Main() + { + var discovered = WebsiteIcons.Discover("", new Uri("https://example.com/page")).ToArray(); + if (discovered.Length != 2 || discovered[0].AbsoluteUri != "https://example.com/assets/logo.png?a=1&b=2" || discovered[1].AbsoluteUri != "https://example.com/favicon.ico") throw new Exception("Icon discovery failed"); + Console.WriteLine("PASS icon discovery resolves relative URLs, entities, and filters unsupported icons"); + foreach (var site in new[] { "https://github.com", "https://www.google.com" }) + Console.WriteLine("Live icon " + site + ": " + (WebsiteIcons.Fetch(site).GetAwaiter().GetResult() != null ? "loaded" : "fallback")); + var app = new App(); app.InitializeComponent(); + var window = new MainWindow(); + var content = (FrameworkElement)window.Content; + var output = Path.Combine(Path.GetTempPath(), "OrbitThemePreview"); Directory.CreateDirectory(output); + foreach (var dark in new[] { false, true, false }) + { + Theme.Apply(dark); + content.Measure(new Size(1100, 680)); content.Arrange(new Rect(0, 0, 1100, 680)); content.UpdateLayout(); + if (((SolidColorBrush)window.Background).Color != (Color)ColorConverter.ConvertFromString(dark ? "#202020" : "#FAF9F6")) throw new Exception("Window theme did not update"); + var bitmap = new RenderTargetBitmap(1100, 680, 96, 96, PixelFormats.Pbgra32); + var frame = new DrawingVisual(); using (var dc = frame.RenderOpen()) { dc.DrawRectangle(window.Background, null, new Rect(0,0,1100,680)); dc.DrawRectangle(new VisualBrush(content), null, new Rect(0,0,1100,680)); } + bitmap.Render(frame); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var file = File.Create(Path.Combine(output, dark ? "dark.png" : "light.png")); encoder.Save(file); + Console.WriteLine("PASS rendered " + (dark ? "dark" : "light") + " theme"); + } + Console.WriteLine(output); + window.Close(); app.Shutdown(); + } +} diff --git a/tests/ThemeChecks.csproj b/tests/ThemeChecks.csproj new file mode 100644 index 0000000..d039ad5 --- /dev/null +++ b/tests/ThemeChecks.csproj @@ -0,0 +1,4 @@ + + Exenet8.0-windowstrueenableenablefalse + +