diff --git a/src/Everything2Everything.App/App.xaml.cs b/src/Everything2Everything.App/App.xaml.cs index 44b3cba..83bb4e0 100644 --- a/src/Everything2Everything.App/App.xaml.cs +++ b/src/Everything2Everything.App/App.xaml.cs @@ -76,15 +76,27 @@ public partial class App : Application private void ShowMainWindow() { - var window = new MainWindow(Engine, Settings); + RunFirstRunSetupIfNeeded(); + var adService = _services.GetService(); + var window = new MainWindow(Engine, Settings, null, adService); MainWindow = window; window.Show(); window.Activate(); } + /// 첫 실행이면 외부 도구 설치 마법사를 한 번 띄우고 완료 플래그를 남긴다(설정 창에서 재진입 가능). + private void RunFirstRunSetupIfNeeded() + { + if (Settings.Get("setup.tools.done") == "1") return; + var wizard = new ToolSetupWindow(); + wizard.ShowDialog(); + Settings.Set("setup.tools.done", "1"); + } + private void ShowConvertDialog(IReadOnlyList files) { - var window = new Views.MainWindow(Engine, Settings, files); + var adService = _services.GetService(); + var window = new Views.MainWindow(Engine, Settings, files, adService); MainWindow = window; window.Show(); window.Activate(); diff --git a/src/Everything2Everything.App/ViewModels/AdViewModel.cs b/src/Everything2Everything.App/ViewModels/AdViewModel.cs new file mode 100644 index 0000000..b5f1b9c --- /dev/null +++ b/src/Everything2Everything.App/ViewModels/AdViewModel.cs @@ -0,0 +1,106 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using Everything2Everything.Core.Ads; + +namespace Everything2Everything.App.ViewModels; + +public class AdViewModel : INotifyPropertyChanged +{ + private readonly IAdService _adService; + private AdItem? _currentBannerAd; + private AdItem? _currentLargeAd; + private bool _isBannerVisible = true; + private bool _isLargeCardVisible = true; + + public event PropertyChangedEventHandler? PropertyChanged; + + public AdItem? CurrentBannerAd + { + get => _currentBannerAd; + set { _currentBannerAd = value; OnPropertyChanged(); } + } + + public AdItem? CurrentLargeAd + { + get => _currentLargeAd; + set { _currentLargeAd = value; OnPropertyChanged(); } + } + + public bool IsBannerVisible + { + get => _isBannerVisible; + set { _isBannerVisible = value; OnPropertyChanged(); } + } + + public bool IsLargeCardVisible + { + get => _isLargeCardVisible; + set { _isLargeCardVisible = value; OnPropertyChanged(); } + } + + public ICommand ClickBannerAdCommand { get; } + public ICommand ClickLargeAdCommand { get; } + public ICommand DismissBannerCommand { get; } + public ICommand DismissLargeCardCommand { get; } + + public AdViewModel(IAdService adService) + { + _adService = adService ?? throw new ArgumentNullException(nameof(adService)); + + CurrentBannerAd = _adService.GetNextBannerAd(); + CurrentLargeAd = _adService.GetNextLargeCardAd(); + + ClickBannerAdCommand = new AdCommand(_ => + { + if (CurrentBannerAd != null) + _adService.OpenAdUrl(CurrentBannerAd); + }); + + ClickLargeAdCommand = new AdCommand(_ => + { + if (CurrentLargeAd != null) + _adService.OpenAdUrl(CurrentLargeAd); + }); + + DismissBannerCommand = new AdCommand(_ => IsBannerVisible = false); + DismissLargeCardCommand = new AdCommand(_ => IsLargeCardVisible = false); + } + + public void RotateBannerAd() + { + CurrentBannerAd = _adService.GetNextBannerAd(); + } + + public void RotateLargeCardAd() + { + CurrentLargeAd = _adService.GetNextLargeCardAd(); + } + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + + private sealed class AdCommand : ICommand + { + private readonly Action _execute; + private readonly Func? _canExecute; + + public AdCommand(Action execute, Func? canExecute = null) + { + _execute = execute; + _canExecute = canExecute; + } + + public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; + public void Execute(object? parameter) => _execute(parameter); + + public event EventHandler? CanExecuteChanged + { + add { } + remove { } + } + } +} diff --git a/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs b/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs new file mode 100644 index 0000000..4f2ce7f --- /dev/null +++ b/src/Everything2Everything.App/ViewModels/ToolSetupViewModel.cs @@ -0,0 +1,184 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Media; +using Everything2Everything.Core.Converters; + +namespace Everything2Everything.App.ViewModels; + +public enum ToolInstallState +{ + NotInstalled, + Installed, + Busy, + Failed, +} + +/// 설치 마법사 목록의 한 행. 카탈로그 정의 + 선택/상태/진행 표현. +public sealed class ToolInstallItem : INotifyPropertyChanged +{ + public ExternalToolDefinition Definition { get; } + public ToolInstallItem(ExternalToolDefinition definition) + { + Definition = definition; + RefreshStatus(); + } + + public string Name => Definition.DisplayName; + public string Description => Definition.Description; + + private bool _isSelected = true; + public bool IsSelected + { + get => _isSelected; + set { _isSelected = value; OnPropertyChanged(); } + } + + private ToolInstallState _state; + public ToolInstallState State + { + get => _state; + private set + { + _state = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsBusy)); + OnPropertyChanged(nameof(StatusText)); + OnPropertyChanged(nameof(StatusBrush)); + } + } + + public string StatusText => State switch + { + ToolInstallState.Installed => "설치됨", + ToolInstallState.Busy => "설치 중…", + ToolInstallState.Failed => "실패", + _ => "미설치", + }; + + public bool IsBusy => State == ToolInstallState.Busy; + + public Brush StatusBrush => State switch + { + ToolInstallState.Installed => BrushOf("FsStatusSuccess"), + ToolInstallState.Busy => BrushOf("FsStatusInfo"), + ToolInstallState.Failed => BrushOf("FsStatusDanger"), + _ => BrushOf("FsStatusWarn"), + }; + + private string? _detail; + public string? Detail + { + get => _detail; + private set { _detail = value; OnPropertyChanged(); } + } + + public void RefreshStatus() + => State = Definition.IsInstalled() ? ToolInstallState.Installed : ToolInstallState.NotInstalled; + + public void ApplyResult(bool success, string? detail) + { + Detail = detail; + State = success ? ToolInstallState.Installed : ToolInstallState.Failed; + } + + public void BeginInstall() => State = ToolInstallState.Busy; + + private static Brush BrushOf(string key) + => (Application.Current?.TryFindResource(key) as Brush) + ?? _fallbacks.GetValueOrDefault(key, _warn); + + private static readonly Brush _info = Fixed(0x1565C0); + private static readonly Brush _success = Fixed(0x2E7D32); + private static readonly Brush _warn = Fixed(0xC77700); + private static readonly Brush _danger = Fixed(0xC62828); + private static readonly Dictionary _fallbacks = new() + { + ["FsStatusInfo"] = _info, + ["FsStatusSuccess"] = _success, + ["FsStatusWarn"] = _warn, + ["FsStatusDanger"] = _danger, + }; + + private static Brush Fixed(uint rgb) + { + var c = (int)rgb; + return new SolidColorBrush(Color.FromRgb((byte)(c >> 16), (byte)(c >> 8), (byte)c)); + } + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} + +/// 설치 마법사의 루트 뷰모델: 목록 + 전체 선택 + 선택분 순차 설치. +public sealed class ToolSetupViewModel : INotifyPropertyChanged +{ + private readonly ExternalToolInstaller _installer; + private bool _isInstalling; + private bool _selectAll = true; + + public ObservableCollection Tools { get; } = new(); + + public ToolSetupViewModel(IEnumerable? definitions = null, ExternalToolInstaller? installer = null) + { + _installer = installer ?? new ExternalToolInstaller(); + foreach (var def in definitions ?? ExternalToolCatalog.All) + Tools.Add(new ToolInstallItem(def)); + } + + public bool IsInstalling + { + get => _isInstalling; + private set { _isInstalling = value; OnPropertyChanged(); } + } + + public bool SelectAll + { + get => _selectAll; + set + { + _selectAll = value; + foreach (var t in Tools) t.IsSelected = value; + OnPropertyChanged(); + } + } + + public void RefreshStatus() + { + foreach (var t in Tools) t.RefreshStatus(); + } + + public async Task InstallSelectedAsync(CancellationToken ct) + { + if (IsInstalling) return; + IsInstalling = true; + try + { + foreach (var item in Tools.Where(t => t.IsSelected).ToList()) + await InstallOneAsync(item, ct).ConfigureAwait(true); + } + finally + { + IsInstalling = false; + } + } + + public async Task InstallOneAsync(ToolInstallItem item, CancellationToken ct) + { + item.BeginInstall(); + var result = await _installer.InstallAsync(item.Definition, ct).ConfigureAwait(true); + var detail = string.IsNullOrWhiteSpace(result.Output) + ? result.Message + : Tail(result.Output, 600); + item.ApplyResult(result.Success, detail); + } + + private static string Tail(string s, int max) + => s.Length <= max ? s : "…" + s[^max..]; + + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged([CallerMemberName] string? name = null) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); +} diff --git a/src/Everything2Everything.App/Views/AdBannerControl.xaml b/src/Everything2Everything.App/Views/AdBannerControl.xaml new file mode 100644 index 0000000..19d5b9c --- /dev/null +++ b/src/Everything2Everything.App/Views/AdBannerControl.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs b/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs new file mode 100644 index 0000000..8791a0f --- /dev/null +++ b/src/Everything2Everything.App/Views/AdBannerControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Everything2Everything.App.Views; + +public partial class AdBannerControl : UserControl +{ + public AdBannerControl() + { + InitializeComponent(); + } +} diff --git a/src/Everything2Everything.App/Views/AdLargeCardControl.xaml b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml new file mode 100644 index 0000000..63fce7e --- /dev/null +++ b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs new file mode 100644 index 0000000..9cd71c5 --- /dev/null +++ b/src/Everything2Everything.App/Views/AdLargeCardControl.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace Everything2Everything.App.Views; + +public partial class AdLargeCardControl : UserControl +{ + public AdLargeCardControl() + { + InitializeComponent(); + } +} diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml b/src/Everything2Everything.App/Views/MainWindow.xaml index 708ccb9..b43f903 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml +++ b/src/Everything2Everything.App/Views/MainWindow.xaml @@ -3,6 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:i="http://schemas.microsoft.com/xaml/behaviors" + xmlns:local="clr-namespace:Everything2Everything.App.Views" Title="Everything2Everything" Icon="pack://application:,,,/Everything2Everything;component/Assets/app-icon.png" Width="1280" Height="960" @@ -327,6 +328,26 @@ + + + + + + + + + + + + + + + BorderThickness="0,0,1,1" Padding="12,8"> - - + + + - - + + + ToolTip="파일명 또는 확장자로 검색" VerticalAlignment="Center" HorizontalAlignment="Stretch"/> - - - - - - - - - + + + diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index 69a4beb..0805fde 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -12,6 +12,7 @@ using Everything2Everything.App.Shell; using Everything2Everything.App.ViewModels; using Everything2Everything.Core; using Everything2Everything.Core.Filters; +using Everything2Everything.Core.Ads; using Everything2Everything.Core.Inspector; using Everything2Everything.Core.Presets; using LossClass = Everything2Everything.Core.Providers.LossClass; @@ -134,15 +135,22 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC if (_selectedCategory != value) { _selectedCategory = value; + if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex != (int)value) + { + CategoryFilterCombo.SelectedIndex = (int)value; + } ApplyQueueFilters(); } } } - public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null) + public AdViewModel AdVm { get; } + + public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null, IAdService? adService = null) { _engine = engine; _settings = settings; + AdVm = new AdViewModel(adService ?? new AdService()); AddFilesCommand = new RelayCommand(_ => PickAndAddFiles()); ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()), @@ -828,6 +836,18 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC } } + private void OnCategoryFilterSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (CategoryFilterCombo?.SelectedItem is ComboBoxItem item && item.Tag is string tag) + { + ApplyFilterCategory(tag); + } + else if (CategoryFilterCombo != null && CategoryFilterCombo.SelectedIndex >= 0) + { + SelectedCategory = (FilterCategory)CategoryFilterCombo.SelectedIndex; + } + } + private void ApplyFilterCategory(string? categoryName) { if (Enum.TryParse(categoryName, true, out var cat)) @@ -1540,9 +1560,25 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyC if (PdfQuickPanel is not null) PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed; + var isText = ext is ".txt" or ".md"; + if (AiQuickPanel is not null) + AiQuickPanel.Visibility = isText ? Visibility.Visible : Visibility.Collapsed; + if (AiTargetLanguagePanel is not null && AiTaskQuickCombo is not null) + AiTargetLanguagePanel.Visibility = (isText && AiTaskQuickCombo.SelectedIndex == 1) + ? Visibility.Visible + : Visibility.Collapsed; + UpdateMediaPanelForFormat(extension); } + private void OnAiTaskSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (AiTargetLanguagePanel is null || AiTaskQuickCombo is null) return; + AiTargetLanguagePanel.Visibility = AiTaskQuickCombo.SelectedIndex == 1 + ? Visibility.Visible + : Visibility.Collapsed; + } + /// 출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴). private void UpdateMediaPanelForFormat(string? extension) { diff --git a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml index d052836..25117b7 100644 --- a/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml +++ b/src/Everything2Everything.App/Views/QuickOptionsWindow.xaml @@ -74,6 +74,20 @@ + + + + + + + + + + +