From a709d4eae302d1ddc52db418b5cf821f81e841b9 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Tue, 10 Feb 2026 20:39:32 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20vers=C3=A3o=20com=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VideoStudy.API/Services/AnalysisService.cs | 133 +++++++++++----- .../Components/Layout/MainLayout.razor | 109 +++++++++---- .../Components/Layout/NavMenu.razor | 96 +++++++++++ .../Components/Pages/Home.razor | 27 ++-- .../Components/Pages/Library.razor | 149 ++++++++++++++++++ .../VideoStudy.Desktop/Program.cs | 1 + .../Services/PersistenceService.cs | 123 +++++++++++++++ .../VideoStudy.Desktop.csproj | 1 + VideoStudy.Native/MauiProgram.cs | 3 + VideoStudy.Shared/Models.cs | 13 ++ .../Services/PersistenceService.cs | 101 ++++++++++++ VideoStudy.Shared/VideoStudy.Shared.csproj | 4 + VideoStudy.UI/Layout/MainLayout.razor | 118 ++++++++++---- VideoStudy.UI/Layout/NavMenu.razor | 97 ++++++++++++ VideoStudy.UI/Pages/Library.razor | 149 ++++++++++++++++++ 15 files changed, 1012 insertions(+), 112 deletions(-) create mode 100644 VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/NavMenu.razor create mode 100644 VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Library.razor create mode 100644 VideoStudy.Desktop/VideoStudy.Desktop/Services/PersistenceService.cs create mode 100644 VideoStudy.Shared/Services/PersistenceService.cs create mode 100644 VideoStudy.UI/Layout/NavMenu.razor create mode 100644 VideoStudy.UI/Pages/Library.razor diff --git a/VideoStudy.API/Services/AnalysisService.cs b/VideoStudy.API/Services/AnalysisService.cs index 1e5123c..2860d47 100644 --- a/VideoStudy.API/Services/AnalysisService.cs +++ b/VideoStudy.API/Services/AnalysisService.cs @@ -70,47 +70,91 @@ public class AnalysisService var tempDir = Path.Combine(Path.GetTempPath(), "VideoStudy", Guid.NewGuid().ToString()); Directory.CreateDirectory(tempDir); - try + string? errorMessage = null; + AnalysisResult? finalResult = null; + + // ETAPA 0: Obter Info do Vídeo + yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Obtendo informações do vídeo..." }; + VideoInfo? videoInfo = null; + try { + videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken); + } catch (Exception ex) { + errorMessage = $"Erro ao acessar o YouTube: {ex.Message}"; + } + + if (errorMessage == null && videoInfo != null) { - yield return new AnalysisEvent { ProgressPercentage = 5, Message = "Iniciando análise técnica..." }; - - var videoInfo = await GetVideoInfoAsync(request.VideoUrl, cancellationToken); - yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Processando: {videoInfo.Title}" }; + yield return new AnalysisEvent { ProgressPercentage = 10, Message = $"Analisando: {videoInfo.Title}" }; + // ETAPA 1: Transcrição yield return new AnalysisEvent { ProgressPercentage = 15, Message = "Obtendo transcrição..." }; - var (transcript, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); - if (string.IsNullOrWhiteSpace(transcript)) throw new Exception("Transcrição indisponível."); - - yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo e gerando resumo..." }; - var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken); - - var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); - if (sectionsToCapture.Any()) - { - yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." }; - await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken); + string? transcript = null; + try { + var (t, _) = await GetTranscriptViaYtDlpAsync(request.VideoUrl, request.Language, tempDir); + transcript = t; + if (string.IsNullOrWhiteSpace(transcript)) errorMessage = "O vídeo não possui transcrição disponível."; + } catch (Exception ex) { + errorMessage = $"Erro na transcrição: {ex.Message}"; } - yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." }; - var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category); - - var result = new AnalysisResult + if (errorMessage == null && transcript != null) { - VideoTitle = videoInfo.Title, - DocumentTitle = docTitle, - Summary = summary, - Category = category, - Transcript = transcript, - TutorialSections = sections, - PdfData = pdfBytes, - RawLlmResponse = rawJson - }; + // ETAPA 2: Inteligência Artificial + yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo técnico..." }; + List? sections = null; + string? rawJson = null, category = null, docTitle = null, summary = null; - yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result }; + try { + var aiResult = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken); + sections = aiResult.sections; + rawJson = aiResult.rawJson; + category = aiResult.category; + docTitle = aiResult.docTitle; + summary = aiResult.summary; + } catch (Exception ex) { + errorMessage = $"IA Indisponível: {ex.Message}. Verifique se excedeu o limite do Groq."; + } + + if (errorMessage == null && sections != null) + { + // ETAPA 3: Screenshots + var sectionsToCapture = sections.Where(s => !string.IsNullOrEmpty(s.ImageTimestamp)).ToList(); + if (sectionsToCapture.Any()) + { + yield return new AnalysisEvent { ProgressPercentage = 70, Message = $"Capturando {sectionsToCapture.Count} imagens críticas..." }; + try { + await CaptureScreenshotsInParallelAsync(request.VideoUrl, sectionsToCapture, videoInfo.Duration, cancellationToken); + } catch { /* Erros de imagem são ignorados para não travar o PDF */ } + } + + // ETAPA 4: PDF + yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando documento PDF..." }; + try { + var pdfBytes = GeneratePdf(docTitle!, summary!, request.VideoUrl, sections, category!); + finalResult = new AnalysisResult { + VideoTitle = videoInfo.Title, + DocumentTitle = docTitle!, + Summary = summary!, + Category = category!, + Transcript = transcript, + TutorialSections = sections, + PdfData = pdfBytes, + RawLlmResponse = rawJson + }; + } catch (Exception ex) { + errorMessage = $"Erro ao gerar PDF: {ex.Message}"; + } + } + } } - finally - { - if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } + + // LIMPEZA E RESULTADO FINAL + try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); } catch { } + + if (errorMessage != null) { + yield return new AnalysisEvent { IsError = true, Message = errorMessage, ProgressPercentage = 100 }; + } else if (finalResult != null) { + yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = finalResult }; } } @@ -139,7 +183,7 @@ public class AnalysisService foreach (var offset in new[] { 0, -1, 1 }) { - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) break; int time = Math.Max(0, target + offset); if (time > duration.TotalSeconds) continue; try { @@ -160,9 +204,12 @@ public class AnalysisService var path = GetYtDlpPath(); var proc = Process.Start(new ProcessStartInfo { FileName = path, Arguments = $"--print title --print channel --print duration --print thumbnail --print description \"{url}\"", RedirectStandardOutput = true, UseShellExecute = false, CreateNoWindow = true }); await proc!.WaitForExitAsync(ct); - var lines = (await proc.StandardOutput.ReadToEndAsync()).Split('\n', StringSplitOptions.RemoveEmptyEntries); + var output = await proc.StandardOutput.ReadToEndAsync(); + var lines = output.Split('\n', StringSplitOptions.RemoveEmptyEntries); + if (lines.Length < 1) throw new Exception("Falha ao ler dados do vídeo via yt-dlp."); + double.TryParse(lines.Length > 2 ? lines[2] : "0", System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var sec); - return new VideoInfo { Title = lines[0].Trim(), Author = lines[1].Trim(), Duration = TimeSpan.FromSeconds(sec), ThumbnailUrl = lines[3].Trim(), Description = lines.Length > 4 ? string.Join("\n", lines.Skip(4)).Trim() : "", Url = url }; + return new VideoInfo { Title = lines[0].Trim(), Author = lines[1].Trim(), Duration = TimeSpan.FromSeconds(sec), ThumbnailUrl = lines.Length > 3 ? lines[3].Trim() : "", Description = lines.Length > 4 ? string.Join("\n", lines.Skip(4)).Trim() : "", Url = url }; } private async Task<(string, string)> GetTranscriptViaYtDlpAsync(string url, string lang, string dir) @@ -212,8 +259,16 @@ Você é um ANALISTA TÉCNICO DE CONTEÚDO especializado em converter vídeos em }} Escreva tudo em {outName}."; - var result = await _kernel.GetRequiredService().GetChatMessageContentAsync(prompt, cancellationToken: ct); - var json = Regex.Match(result.Content ?? "{}", @"\{[\s\S]*\}").Value; + var chatService = _kernel.GetRequiredService(); + var result = await chatService.GetChatMessageContentAsync(prompt, cancellationToken: ct); + + string rawContent = result.Content ?? "{}"; + _logger.LogInformation("Resposta bruta da IA: {RawContent}", rawContent); + + var jsonMatch = Regex.Match(rawContent, @"\{[\s\S]*\}"); + if (!jsonMatch.Success) throw new Exception("A IA não retornou um JSON válido."); + + string json = jsonMatch.Value; using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -236,6 +291,7 @@ Escreva tudo em {outName}."; { try { using var img = SKBitmap.Decode(bytes); + if (img == null) return -1; var lumas = new List(); for (int y = 0; y < img.Height; y += 20) for (int x = 0; x < img.Width; x += 20) { var p = img.GetPixel(x,y); @@ -261,7 +317,6 @@ Escreva tudo em {outName}."; c.Item().PaddingTop(5).LineHorizontal(1).LineColor(Colors.Grey.Lighten2); }); page.Content().PaddingVertical(1, Unit.Centimetre).Column(col => { - // Resumo Section col.Item().Background(Colors.Grey.Lighten4).Padding(10).Column(rc => { rc.Item().Text("Resumo").Bold().FontSize(12).FontColor(Colors.Blue.Medium); rc.Item().PaddingTop(2).Text(summary).Italic(); diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/MainLayout.razor b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/MainLayout.razor index bf3c2f9..f3b0e66 100644 --- a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/MainLayout.razor +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/MainLayout.razor @@ -1,25 +1,25 @@ @inherits LayoutComponentBase
- + -
- @Body +
+ + +
+ @Body +
- + An unhandled error has occurred. + Reload + 🗙
+ \ No newline at end of file diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/NavMenu.razor b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/NavMenu.razor new file mode 100644 index 0000000..bbaa4b9 --- /dev/null +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Layout/NavMenu.razor @@ -0,0 +1,96 @@ + + + + + diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor index aa5aec5..4714890 100644 --- a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Home.razor @@ -2,9 +2,8 @@ @rendermode InteractiveServer @inject HttpClient Http @inject VideoStudy.Desktop.Services.YouTubeService YouTubeService -@inject VideoStudy.Desktop.Services.TranscriptionService TranscriptionService -@inject VideoStudy.Desktop.Services.ScreenshotService ScreenshotService -@inject VideoStudy.Desktop.Services.PdfGeneratorService PdfGeneratorService +@inject VideoStudy.Desktop.Services.PersistenceService PersistenceService +@inject NavigationManager NavigationManager @using VideoStudy.Shared @using System.Net.Http.Json @using System.Text.Json @@ -303,17 +302,23 @@ // Final result received AddLog("✅ Análise completa!", 100); - // Save PDF + // Save PDF via Persistence Service if (analysisEvent.Result.PdfData != null && analysisEvent.Result.PdfData.Length > 0) { - // Blazor Hybrid approach: saving directly to Downloads folder - string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - string fileName = $"VideoStudy_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; - string fullPath = Path.Combine(downloadsPath, fileName); + AddLog("💾 Gravando apostila na biblioteca local...", 100); - await File.WriteAllBytesAsync(fullPath, analysisEvent.Result.PdfData); - generatedPdfPath = fullPath; - AddLog($"📄 PDF Salvo em: {fullPath}", 100); + var session = await PersistenceService.SaveSessionAsync( + analysisEvent.Result.PdfData, + analysisEvent.Result.DocumentTitle, + requestBody.VideoUrl // YouTube ID extractor could be added here + ); + + generatedPdfPath = session.FilePath; + AddLog($"📄 PDF Arquivado em: {session.FilePath}", 100); + + // Redirecionamento automático após 1.5 segundos para o usuário ver a mensagem de sucesso + await Task.Delay(1500); + NavigationManager.NavigateTo("library"); } else { diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Library.razor b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Library.razor new file mode 100644 index 0000000..88f4e15 --- /dev/null +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Components/Pages/Library.razor @@ -0,0 +1,149 @@ +@page "/library" +@inject VideoStudy.Desktop.Services.PersistenceService PersistenceService +@using VideoStudy.Shared +@using System.Diagnostics + +
+
+

📚 Minha Biblioteca

+ @if (!string.IsNullOrEmpty(currentFolder)) + { + + } +
+ + @if (string.IsNullOrEmpty(currentFolder)) + { + +
+ +
+
+
+
📁
+
Geral
+ @GetCount("Geral") arquivos +
+
+
+ + + @foreach (var folder in folders) + { + if (folder == "Geral") continue; +
+
+
+
📁
+
@folder
+ @GetCount(folder) arquivos +
+
+
+ } +
+ } + else + { + +
Pasta: @currentFolder
+ +
+ @foreach (var session in currentSessions) + { +
+
📄
+
+
@session.Title
+ + 📅 @session.CreatedAt.ToString("g") • + @session.FilePath + +
+ +
+ } + + @if (!currentSessions.Any()) + { +
+
Esta pasta está vazia 🕸️
+
+ } +
+ } +
+ + + +@code { + private List folders = new(); + private List allSessions = new(); + private List currentSessions = new(); + private string? currentFolder = null; + + protected override void OnInitialized() + { + LoadData(); + } + + private void LoadData() + { + // Ler estrutura física e banco + folders = PersistenceService.GetFolders(); + if (!folders.Contains("Geral")) folders.Insert(0, "Geral"); // Garantir Geral + + allSessions = PersistenceService.GetAllSessions(); + } + + private int GetCount(string folder) + { + return allSessions.Count(s => s.FolderName == folder); + } + + private void OpenFolder(string folderName) + { + currentFolder = folderName; + currentSessions = allSessions.Where(s => s.FolderName == folderName).ToList(); + } + + private void GoBack() + { + currentFolder = null; + } + + private void OpenPdf(string path) + { + try + { + if (File.Exists(path)) + { + new Process + { + StartInfo = new ProcessStartInfo(path) { UseShellExecute = true } + }.Start(); + } + } + catch + { + // Tratamento simples, idealmente mostraria um toast + } + } +} diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Program.cs b/VideoStudy.Desktop/VideoStudy.Desktop/Program.cs index 4fb6915..cb8b606 100644 --- a/VideoStudy.Desktop/VideoStudy.Desktop/Program.cs +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Program.cs @@ -18,6 +18,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Updated to Shared builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/Services/PersistenceService.cs b/VideoStudy.Desktop/VideoStudy.Desktop/Services/PersistenceService.cs new file mode 100644 index 0000000..3fc9f41 --- /dev/null +++ b/VideoStudy.Desktop/VideoStudy.Desktop/Services/PersistenceService.cs @@ -0,0 +1,123 @@ +using LiteDB; +using VideoStudy.Shared; + +namespace VideoStudy.Desktop.Services; + +public class PersistenceService +{ + private readonly string _basePath; + private readonly string _dbPath; + + public PersistenceService() + { + // Define a pasta base em %USERPROFILE%/MeuVideoStudy + _basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "MeuVideoStudy"); + _dbPath = Path.Combine(_basePath, "videostudy.db"); + + if (!Directory.Exists(_basePath)) + { + Directory.CreateDirectory(_basePath); + } + } + + public string GetBasePath() => _basePath; + + /// + /// Retorna as subpastas físicas dentro do diretório base. + /// + public List GetFolders() + { + return Directory.GetDirectories(_basePath) + .Select(Path.GetFileName) + .Where(name => name != null) + .ToList()!; + } + + /// + /// Cria uma nova pasta física. + /// + public void CreateFolder(string folderName) + { + var path = Path.Combine(_basePath, folderName); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + /// + /// Renomeia uma pasta física. + /// + public void RenameFolder(string oldName, string newName) + { + var oldPath = Path.Combine(_basePath, oldName); + var newPath = Path.Combine(_basePath, newName); + if (Directory.Exists(oldPath) && !Directory.Exists(newPath)) + { + Directory.Move(oldPath, newPath); + } + } + + /// + /// Salva o PDF no disco e registra no LiteDB. + /// + public async Task SaveSessionAsync(byte[] pdfData, string title, string? youtubeId, string folderName = "Geral") + { + // Garantir que a pasta existe + var folderPath = Path.Combine(_basePath, folderName); + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + // Criar nome de arquivo seguro + var safeTitle = string.Join("_", title.Split(Path.GetInvalidFileNameChars())); + var fileName = $"{safeTitle}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + var filePath = Path.Combine(folderPath, fileName); + + // Gravar arquivo físico + await File.WriteAllBytesAsync(filePath, pdfData); + + // Registrar no LiteDB + using var db = new LiteDatabase(_dbPath); + var collection = db.GetCollection("sessions"); + + var session = new VideoStudySession + { + Title = title, + YouTubeId = youtubeId ?? "local", + FilePath = filePath, + FolderName = folderName, + CreatedAt = DateTime.Now + }; + + collection.Insert(session); + collection.EnsureIndex(x => x.Title); + + return session; + } + + /// + /// Recupera todas as sessões do banco. + /// + public List GetAllSessions() + { + using var db = new LiteDatabase(_dbPath); + return db.GetCollection("sessions") + .FindAll() + .OrderByDescending(x => x.CreatedAt) + .ToList(); + } + + /// + /// Busca sessões por pasta. + /// + public List GetSessionsByFolder(string folderName) + { + using var db = new LiteDatabase(_dbPath); + return db.GetCollection("sessions") + .Find(x => x.FolderName == folderName) + .OrderByDescending(x => x.CreatedAt) + .ToList(); + } +} diff --git a/VideoStudy.Desktop/VideoStudy.Desktop/VideoStudy.Desktop.csproj b/VideoStudy.Desktop/VideoStudy.Desktop/VideoStudy.Desktop.csproj index 73ce6c9..da41d9e 100644 --- a/VideoStudy.Desktop/VideoStudy.Desktop/VideoStudy.Desktop.csproj +++ b/VideoStudy.Desktop/VideoStudy.Desktop/VideoStudy.Desktop.csproj @@ -8,6 +8,7 @@ + diff --git a/VideoStudy.Native/MauiProgram.cs b/VideoStudy.Native/MauiProgram.cs index 28c9428..4411ab3 100644 --- a/VideoStudy.Native/MauiProgram.cs +++ b/VideoStudy.Native/MauiProgram.cs @@ -32,6 +32,9 @@ public static class MauiProgram // PDF Saver (FileSavePicker do Windows) builder.Services.AddSingleton(); + // Persistence Service (LiteDB) + builder.Services.AddScoped(); + return builder.Build(); } } diff --git a/VideoStudy.Shared/Models.cs b/VideoStudy.Shared/Models.cs index 4cc5bc8..9ef791d 100644 --- a/VideoStudy.Shared/Models.cs +++ b/VideoStudy.Shared/Models.cs @@ -145,4 +145,17 @@ public class AnalysisResult public List TutorialSections { get; set; } = []; public byte[]? PdfData { get; set; } public string? RawLlmResponse { get; set; } +} + +/// +/// Representa uma sessão de estudo gravada no banco de dados local. +/// +public class VideoStudySession +{ + public int Id { get; set; } + public string YouTubeId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string FilePath { get; set; } = string.Empty; + public string FolderName { get; set; } = "Geral"; + public DateTime CreatedAt { get; set; } = DateTime.Now; } \ No newline at end of file diff --git a/VideoStudy.Shared/Services/PersistenceService.cs b/VideoStudy.Shared/Services/PersistenceService.cs new file mode 100644 index 0000000..d893ee1 --- /dev/null +++ b/VideoStudy.Shared/Services/PersistenceService.cs @@ -0,0 +1,101 @@ +using LiteDB; +using VideoStudy.Shared; + +namespace VideoStudy.Shared.Services; + +public class PersistenceService +{ + private readonly string _basePath; + private readonly string _dbPath; + + public PersistenceService() + { + // Define a pasta base em %USERPROFILE%/MeuVideoStudy + // Funciona em Windows, macOS e Linux (limitado pelo ambiente MAUI) + _basePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "MeuVideoStudy"); + _dbPath = Path.Combine(_basePath, "videostudy.db"); + + if (!Directory.Exists(_basePath)) + { + Directory.CreateDirectory(_basePath); + } + } + + public string GetBasePath() => _basePath; + + /// + /// Retorna as subpastas físicas dentro do diretório base. + /// + public List GetFolders() + { + if (!Directory.Exists(_basePath)) return new List(); + + return Directory.GetDirectories(_basePath) + .Select(Path.GetFileName) + .Where(name => name != null) + .ToList()!; + } + + /// + /// Cria uma nova pasta física. + /// + public void CreateFolder(string folderName) + { + var path = Path.Combine(_basePath, folderName); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + /// + /// Salva o PDF no disco e registra no LiteDB. + /// + public async Task SaveSessionAsync(byte[] pdfData, string title, string? youtubeId, string folderName = "Geral") + { + // Garantir que a pasta existe + var folderPath = Path.Combine(_basePath, folderName); + if (!Directory.Exists(folderPath)) + { + Directory.CreateDirectory(folderPath); + } + + // Criar nome de arquivo seguro + var safeTitle = string.Join("_", title.Split(Path.GetInvalidFileNameChars())); + var fileName = $"{safeTitle}_{DateTime.Now:yyyyMMdd_HHmmss}.pdf"; + var filePath = Path.Combine(folderPath, fileName); + + // Gravar arquivo físico + await File.WriteAllBytesAsync(filePath, pdfData); + + // Registrar no LiteDB + using var db = new LiteDatabase(_dbPath); + var collection = db.GetCollection("sessions"); + + var session = new VideoStudySession + { + Title = title, + YouTubeId = youtubeId ?? "local", + FilePath = filePath, + FolderName = folderName, + CreatedAt = DateTime.Now + }; + + collection.Insert(session); + collection.EnsureIndex(x => x.Title); + + return session; + } + + /// + /// Recupera todas as sessões do banco. + /// + public List GetAllSessions() + { + using var db = new LiteDatabase(_dbPath); + return db.GetCollection("sessions") + .FindAll() + .OrderByDescending(x => x.CreatedAt) + .ToList(); + } +} diff --git a/VideoStudy.Shared/VideoStudy.Shared.csproj b/VideoStudy.Shared/VideoStudy.Shared.csproj index c62bfe3..157b003 100644 --- a/VideoStudy.Shared/VideoStudy.Shared.csproj +++ b/VideoStudy.Shared/VideoStudy.Shared.csproj @@ -7,4 +7,8 @@ AnyCPU;x64 + + + + diff --git a/VideoStudy.UI/Layout/MainLayout.razor b/VideoStudy.UI/Layout/MainLayout.razor index 47b4e97..f3a1f1a 100644 --- a/VideoStudy.UI/Layout/MainLayout.razor +++ b/VideoStudy.UI/Layout/MainLayout.razor @@ -1,25 +1,25 @@ @inherits LayoutComponentBase
- + -
- @Body +
+ + +
+ @Body +
- + An unhandled error has occurred. + Reload + 🗙
+ + .top-row ::deep a, .top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; + } + + .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { + text-decoration: underline; + } + + .top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + + @@media (max-width: 640.98px) { + .top-row:not(.auth) { + display: none; + } + + .top-row.auth { + justify-content: space-between; + } + + .top-row ::deep a, .top-row ::deep .btn-link { + margin-left: 0; + } + } + + @@media (min-width: 641px) { + .page { + flex-direction: row; + } + + .sidebar { + width: 250px; + height: 100vh; + position: sticky; + top: 0; + } + + .top-row { + position: sticky; + top: 0; + z-index: 1; + } + + .top-row.auth ::deep a:first-child { + flex: 1; + text-align: right; + width: 0; + } + + .top-row, article { + padding-left: 2rem !important; + padding-right: 1.5rem !important; + } + } + \ No newline at end of file diff --git a/VideoStudy.UI/Layout/NavMenu.razor b/VideoStudy.UI/Layout/NavMenu.razor new file mode 100644 index 0000000..777ff70 --- /dev/null +++ b/VideoStudy.UI/Layout/NavMenu.razor @@ -0,0 +1,97 @@ + + + + + diff --git a/VideoStudy.UI/Pages/Library.razor b/VideoStudy.UI/Pages/Library.razor new file mode 100644 index 0000000..e88af74 --- /dev/null +++ b/VideoStudy.UI/Pages/Library.razor @@ -0,0 +1,149 @@ +@page "/library" +@inject VideoStudy.Shared.Services.PersistenceService PersistenceService +@using VideoStudy.Shared +@using System.Diagnostics + +
+
+

📚 Minha Biblioteca

+ @if (!string.IsNullOrEmpty(currentFolder)) + { + + } +
+ + @if (string.IsNullOrEmpty(currentFolder)) + { + +
+ +
+
+
+
📁
+
Geral
+ @GetCount("Geral") arquivos +
+
+
+ + + @foreach (var folder in folders) + { + if (folder == "Geral") continue; +
+
+
+
📁
+
@folder
+ @GetCount(folder) arquivos +
+
+
+ } +
+ } + else + { + +
Pasta: @currentFolder
+ +
+ @foreach (var session in currentSessions) + { +
+
📄
+
+
@session.Title
+ + 📅 @session.CreatedAt.ToString("g") • + @session.FilePath + +
+ +
+ } + + @if (!currentSessions.Any()) + { +
+
Esta pasta está vazia 🕸️
+
+ } +
+ } +
+ + + +@code { + private List folders = new(); + private List allSessions = new(); + private List currentSessions = new(); + private string? currentFolder = null; + + protected override void OnInitialized() + { + LoadData(); + } + + private void LoadData() + { + // Ler estrutura física e banco + folders = PersistenceService.GetFolders(); + if (!folders.Contains("Geral")) folders.Insert(0, "Geral"); // Garantir Geral + + allSessions = PersistenceService.GetAllSessions(); + } + + private int GetCount(string folder) + { + return allSessions.Count(s => s.FolderName == folder); + } + + private void OpenFolder(string folderName) + { + currentFolder = folderName; + currentSessions = allSessions.Where(s => s.FolderName == folderName).ToList(); + } + + private void GoBack() + { + currentFolder = null; + } + + private void OpenPdf(string path) + { + try + { + if (File.Exists(path)) + { + new Process + { + StartInfo = new ProcessStartInfo(path) { UseShellExecute = true } + }.Start(); + } + } + catch + { + // Tratamento simples, idealmente mostraria um toast + } + } +}