Initial release of Orbit 1.1.2
This commit is contained in:
commit
93ab510b9f
20 files changed
+1121
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
portable/
|
||||||
|
release/
|
||||||
|
*.zip
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
settings.json*
|
||||||
|
shortcuts.json*
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<Application x:Class="ShortcutsPanel.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
|
||||||
|
<Application.Resources>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="Ink" Color="#252924"/>
|
||||||
|
|
||||||
|
<Style TargetType="Window"><Setter Property="Background" Value="{DynamicResource WindowBackground}"/><Setter Property="Foreground" Value="{DynamicResource Ink}"/><Setter Property="FontFamily" Value="Segoe UI"/></Style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<Style TargetType="Button">
|
||||||
|
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Ink}"/><Setter Property="Background" Value="{DynamicResource ControlBackground}"/><Setter Property="Padding" Value="16,10"/><Setter Property="Cursor" Value="Hand"/><Setter Property="BorderThickness" Value="0"/>
|
||||||
|
|
||||||
|
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button"><Border x:Name="Surface" Background="{TemplateBinding Background}" CornerRadius="7" Padding="{TemplateBinding Padding}"><ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/></Border><ControlTemplate.Triggers><Trigger Property="IsMouseOver" Value="True"><Setter TargetName="Surface" Property="Opacity" Value="0.78"/></Trigger><Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Surface" Property="BorderBrush" Value="{DynamicResource FocusBorder}"/><Setter TargetName="Surface" Property="BorderThickness" Value="2"/></Trigger><Trigger Property="IsEnabled" Value="False"><Setter TargetName="Surface" Property="Opacity" Value="0.4"/></Trigger></ControlTemplate.Triggers></ControlTemplate></Setter.Value></Setter>
|
||||||
|
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="TextBox">
|
||||||
|
|
||||||
|
<Setter Property="MinHeight" Value="42"/>
|
||||||
|
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBackground}"/>
|
||||||
|
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Ink}"/>
|
||||||
|
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource Ink}"/>
|
||||||
|
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InputBorder}"/>
|
||||||
|
|
||||||
|
<Setter Property="Padding" Value="12,6"/>
|
||||||
|
|
||||||
|
<Setter Property="FontSize" Value="14"/>
|
||||||
|
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="ThemeSwitch" TargetType="CheckBox">
|
||||||
|
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource Ink}"/>
|
||||||
|
|
||||||
|
<Setter Property="Cursor" Value="Hand"/>
|
||||||
|
|
||||||
|
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="CheckBox">
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
|
||||||
|
<Border x:Name="Track" Width="36" Height="20" CornerRadius="10" Background="{DynamicResource SwitchOff}" Margin="0,0,10,0">
|
||||||
|
|
||||||
|
<Ellipse x:Name="Thumb" Width="14" Height="14" Fill="White" HorizontalAlignment="Left" Margin="3,0,3,0"/>
|
||||||
|
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ContentPresenter VerticalAlignment="Center"/>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
|
||||||
|
<Trigger Property="IsChecked" Value="True"><Setter TargetName="Track" Property="Background" Value="{DynamicResource SwitchOn}"/><Setter TargetName="Thumb" Property="HorizontalAlignment" Value="Right"/></Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="Track" Property="BorderBrush" Value="{DynamicResource SwitchFocus}"/><Setter TargetName="Track" Property="BorderThickness" Value="2"/></Trigger>
|
||||||
|
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
|
||||||
|
</ControlTemplate></Setter.Value></Setter>
|
||||||
|
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style x:Key="CardButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
|
||||||
|
<Setter Property="FocusVisualStyle" Value="{x:Null}"/>
|
||||||
|
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="Button">
|
||||||
|
<Border Background="{TemplateBinding Background}" Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate></Setter.Value></Setter>
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
|
||||||
|
</Application>
|
||||||
|
|
||||||
+10
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace ShortcutsPanel;
|
||||||
|
|
||||||
|
public sealed record LibraryExport(int Version, List<Shortcut> Links, List<string> Collections);
|
||||||
|
|
||||||
|
public static class LibraryOperations
|
||||||
|
{
|
||||||
|
public static List<Shortcut> Validate(IEnumerable<Shortcut> input)
|
||||||
|
{
|
||||||
|
var result = new List<Shortcut>(); var ids = new HashSet<string>();
|
||||||
|
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<string> Collections(IEnumerable<Shortcut> entries, IEnumerable<string> 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<T> MoveBefore<T>(IEnumerable<T> source, T moving, T target)
|
||||||
|
{
|
||||||
|
var list = source.ToList();
|
||||||
|
if (EqualityComparer<T>.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<T> MoveAfter<T>(IEnumerable<T> source, T moving, T target)
|
||||||
|
{
|
||||||
|
var list = source.ToList();
|
||||||
|
if (EqualityComparer<T>.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<Shortcut>? links; List<string> order = [];
|
||||||
|
if (document.RootElement.ValueKind == JsonValueKind.Array) links = document.RootElement.Deserialize<List<Shortcut>>();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var exported = document.RootElement.Deserialize<LibraryExport>() ?? 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<Shortcut> Merge(IEnumerable<Shortcut> existing, IEnumerable<Shortcut> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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<bool>? 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+221
@@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+56
@@ -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.
|
||||||
@@ -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<string> 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<Data>(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<string> LoadCollections() => Read().Collections ?? [];
|
||||||
|
public void SaveLibrary(string json, List<string> 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); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationIcon>app.ico</ApplicationIcon>
|
||||||
|
<AssemblyName>ShortcutsPanel</AssemblyName>
|
||||||
|
<Version>1.1.2</Version>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup><Compile Remove="tests/**/*.cs" /></ItemGroup>
|
||||||
|
<ItemGroup><EmbeddedResource Include="app.ico" LogicalName="OrbitIcon" /></ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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<string, string> 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<string, SolidColorBrush> Brushes = new(StringComparer.OrdinalIgnoreCase);␍
|
||||||
|
static readonly Dictionary<string, ThemeColor> 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");␍
|
||||||
|
}␍
|
||||||
|
}␍
|
||||||
@@ -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<Uri> Discover(string html, Uri page)
|
||||||
|
{
|
||||||
|
var baseTag = Regex.Match(html, @"<base\b[^>]*>", 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, @"<link\b[^>]*>", 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<string, string> Attributes(string tag)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string>(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<BitmapSource?> Fetch(string address)
|
||||||
|
{
|
||||||
|
await Slots.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
|
||||||
|
var page = new Uri(address); var candidates = new List<Uri>();
|
||||||
|
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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<Shortcut>>(store.Load()!)![0].Name == "C", "Card order persists");
|
||||||
|
Check((bool)Call("DeleteShortcut", original[0])!, "Delete shortcut");
|
||||||
|
Check(JsonSerializer.Deserialize<List<Shortcut>>(store.Load()!)!.Count == 2, "Deletion saved");
|
||||||
|
Call("UndoDelete");
|
||||||
|
Check(JsonSerializer.Deserialize<List<Shortcut>>(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<List<Shortcut>>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
|
||||||
|
<ItemGroup><Compile Include="FeatureChecks.cs"/><ProjectReference Include="../ShortcutsPanel.csproj"/></ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
|
||||||
|
<ItemGroup><Compile Remove="ThemeChecks.cs;FeatureChecks.cs"/><Compile Include="../SettingsStore.cs" Link="SettingsStore.cs" /></ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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("<base href='/assets/'><link href='logo.png?a=1&b=2' rel='apple-touch-icon'><link rel='shortcut icon' href=../favicon.ico><link rel='icon' href='icon.svg'><link rel='icon' href='javascript:alert(1)'>", 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><EnableDefaultCompileItems>false</EnableDefaultCompileItems></PropertyGroup>
|
||||||
|
<ItemGroup><Compile Include="ThemeChecks.cs"/><ProjectReference Include="../ShortcutsPanel.csproj"/></ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in new issue
Block a user