feat: versão com menu

This commit is contained in:
Ricardo Carneiro 2026-02-10 20:39:32 -03:00
parent 7417f1f6c6
commit a709d4eae3
15 changed files with 1012 additions and 112 deletions

View File

@ -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.");
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 = 40, Message = "IA estruturando conteúdo e gerando resumo..." };
var (sections, rawJson, category, docTitle, summary) = await GenerateTutorialContentAsync(transcript, videoInfo, request.Language, request.OutputLanguage, cancellationToken);
if (errorMessage == null && transcript != null)
{
// ETAPA 2: Inteligência Artificial
yield return new AnalysisEvent { ProgressPercentage = 40, Message = "IA estruturando conteúdo técnico..." };
List<TutorialSection>? sections = null;
string? rawJson = null, category = null, docTitle = null, summary = null;
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 */ }
}
yield return new AnalysisEvent { ProgressPercentage = 90, Message = "Gerando PDF..." };
var pdfBytes = GeneratePdf(docTitle, summary, request.VideoUrl, sections, category);
var result = new AnalysisResult
{
// 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,
DocumentTitle = docTitle!,
Summary = summary!,
Category = category!,
Transcript = transcript,
TutorialSections = sections,
PdfData = pdfBytes,
RawLlmResponse = rawJson
};
yield return new AnalysisEvent { ProgressPercentage = 100, Message = "Concluído!", Result = result };
} 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<IChatCompletionService>().GetChatMessageContentAsync(prompt, cancellationToken: ct);
var json = Regex.Match(result.Content ?? "{}", @"\{[\s\S]*\}").Value;
var chatService = _kernel.GetRequiredService<IChatCompletionService>();
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<float>();
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();

View File

@ -1,25 +1,25 @@
@inherits LayoutComponentBase
<div class="page">
<nav class="navbar navbar-dark bg-dark sticky-top mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">📺 VideoStudy</span>
<div class="sidebar">
<NavMenu />
</div>
</nav>
<main role="main" class="px-4">
<main>
<div class="top-row px-4">
<a href="https://github.com/microsoft/semantic-kernel" target="_blank">About Semantic Kernel</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
<div class="alert alert-danger m-3" role="alert">
<h4 class="alert-heading">⚠️ Unhandled error</h4>
<p>An unhandled error has occurred. Please reload the page.</p>
<hr>
<button class="btn btn-primary reload">Reload</button>
<button class="btn btn-secondary dismiss">Dismiss</button>
</div>
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<style>
@ -27,32 +27,81 @@
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
#blazor-error-ui {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
justify-content: center;
}
#blazor-error-ui.show {
display: flex;
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.reload, .dismiss {
cursor: pointer;
.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;
}
}
</style>

View File

@ -0,0 +1,96 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">VideoStudy</a>
</div>
</div>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Início
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="library">
<span class="bi bi-folder-fill-nav-menu" aria-hidden="true"></span> Minhas Pastas
</NavLink>
</div>
</nav>
</div>
<style>
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.495v3.505a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z'/%3E%3C/svg%3E");
}
.bi-folder-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-folder-fill' viewBox='0 0 16 16'%3E%3Cpath d='M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep a {
color: #d7d7d7;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep a:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler {
background-color: rgba(255, 255, 255, 0.1);
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
display: block;
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
</style>

View File

@ -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
{

View File

@ -0,0 +1,149 @@
@page "/library"
@inject VideoStudy.Desktop.Services.PersistenceService PersistenceService
@using VideoStudy.Shared
@using System.Diagnostics
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold text-dark">📚 Minha Biblioteca</h2>
@if (!string.IsNullOrEmpty(currentFolder))
{
<button class="btn btn-outline-secondary" @onclick="GoBack">
⬅️ Voltar
</button>
}
</div>
@if (string.IsNullOrEmpty(currentFolder))
{
<!-- MODO PASTAS -->
<div class="row g-3">
<!-- Pasta Geral (Padrão) -->
<div class="col-md-3">
<div class="card h-100 shadow-sm folder-card" @onclick='() => OpenFolder("Geral")'>
<div class="card-body text-center p-4">
<div class="display-1 mb-2">📁</div>
<h5 class="fw-bold">Geral</h5>
<small class="text-muted">@GetCount("Geral") arquivos</small>
</div>
</div>
</div>
<!-- Outras Pastas do Disco -->
@foreach (var folder in folders)
{
if (folder == "Geral") continue;
<div class="col-md-3">
<div class="card h-100 shadow-sm folder-card" @onclick='() => OpenFolder(folder)'>
<div class="card-body text-center p-4">
<div class="display-1 mb-2">📁</div>
<h5 class="fw-bold">@folder</h5>
<small class="text-muted">@GetCount(folder) arquivos</small>
</div>
</div>
</div>
}
</div>
}
else
{
<!-- MODO ARQUIVOS (DENTRO DA PASTA) -->
<h5 class="mb-3 text-muted">Pasta: <strong>@currentFolder</strong></h5>
<div class="list-group shadow-sm">
@foreach (var session in currentSessions)
{
<div class="list-group-item list-group-item-action d-flex align-items-center p-3">
<div class="me-3 fs-1">📄</div>
<div class="flex-grow-1">
<h6 class="mb-1 fw-bold">@session.Title</h6>
<small class="text-muted">
📅 @session.CreatedAt.ToString("g") •
<span class="text-truncate d-inline-block" style="max-width: 300px;">@session.FilePath</span>
</small>
</div>
<button class="btn btn-primary btn-sm px-4 fw-bold" @onclick="() => OpenPdf(session.FilePath)">
Abrir PDF ↗️
</button>
</div>
}
@if (!currentSessions.Any())
{
<div class="text-center p-5 text-muted">
<h5>Esta pasta está vazia 🕸️</h5>
</div>
}
</div>
}
</div>
<style>
.folder-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
border: none;
background: #f8f9fa;
}
.folder-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important;
background: #fff;
border: 1px solid #dee2e6;
}
</style>
@code {
private List<string> folders = new();
private List<VideoStudySession> allSessions = new();
private List<VideoStudySession> 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
}
}
}

View File

@ -18,6 +18,7 @@ builder.Services.AddScoped<VideoStudy.Desktop.Services.YouTubeService>();
builder.Services.AddScoped<VideoStudy.Desktop.Services.TranscriptionService>();
builder.Services.AddScoped<VideoStudy.Desktop.Services.ScreenshotService>();
builder.Services.AddScoped<VideoStudy.Desktop.Services.PdfGeneratorService>();
builder.Services.AddScoped<VideoStudy.Shared.Services.PersistenceService>(); // Updated to Shared
builder.Services.AddSingleton<VideoStudy.Shared.Services.IHardwareIdService, VideoStudy.Shared.Services.HardwareIdService>();
builder.Services.AddScoped<VideoStudy.Desktop.Services.LicenseManager>();

View File

@ -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;
/// <summary>
/// Retorna as subpastas físicas dentro do diretório base.
/// </summary>
public List<string> GetFolders()
{
return Directory.GetDirectories(_basePath)
.Select(Path.GetFileName)
.Where(name => name != null)
.ToList()!;
}
/// <summary>
/// Cria uma nova pasta física.
/// </summary>
public void CreateFolder(string folderName)
{
var path = Path.Combine(_basePath, folderName);
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
/// <summary>
/// Renomeia uma pasta física.
/// </summary>
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);
}
}
/// <summary>
/// Salva o PDF no disco e registra no LiteDB.
/// </summary>
public async Task<VideoStudySession> 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<VideoStudySession>("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;
}
/// <summary>
/// Recupera todas as sessões do banco.
/// </summary>
public List<VideoStudySession> GetAllSessions()
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<VideoStudySession>("sessions")
.FindAll()
.OrderByDescending(x => x.CreatedAt)
.ToList();
}
/// <summary>
/// Busca sessões por pasta.
/// </summary>
public List<VideoStudySession> GetSessionsByFolder(string folderName)
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<VideoStudySession>("sessions")
.Find(x => x.FolderName == folderName)
.OrderByDescending(x => x.CreatedAt)
.ToList();
}
}

View File

@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LiteDB" Version="5.0.21" />
<PackageReference Include="QuestPDF" Version="2025.12.4" />
<PackageReference Include="Whisper.net" Version="1.9.0" />
<PackageReference Include="Whisper.net.Runtime" Version="1.9.0" />

View File

@ -32,6 +32,9 @@ public static class MauiProgram
// PDF Saver (FileSavePicker do Windows)
builder.Services.AddSingleton<IPdfSaver, WindowsPdfSaver>();
// Persistence Service (LiteDB)
builder.Services.AddScoped<VideoStudy.Shared.Services.PersistenceService>();
return builder.Build();
}
}

View File

@ -146,3 +146,16 @@ public class AnalysisResult
public byte[]? PdfData { get; set; }
public string? RawLlmResponse { get; set; }
}
/// <summary>
/// Representa uma sessão de estudo gravada no banco de dados local.
/// </summary>
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;
}

View File

@ -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;
/// <summary>
/// Retorna as subpastas físicas dentro do diretório base.
/// </summary>
public List<string> GetFolders()
{
if (!Directory.Exists(_basePath)) return new List<string>();
return Directory.GetDirectories(_basePath)
.Select(Path.GetFileName)
.Where(name => name != null)
.ToList()!;
}
/// <summary>
/// Cria uma nova pasta física.
/// </summary>
public void CreateFolder(string folderName)
{
var path = Path.Combine(_basePath, folderName);
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
}
/// <summary>
/// Salva o PDF no disco e registra no LiteDB.
/// </summary>
public async Task<VideoStudySession> 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<VideoStudySession>("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;
}
/// <summary>
/// Recupera todas as sessões do banco.
/// </summary>
public List<VideoStudySession> GetAllSessions()
{
using var db = new LiteDatabase(_dbPath);
return db.GetCollection<VideoStudySession>("sessions")
.FindAll()
.OrderByDescending(x => x.CreatedAt)
.ToList();
}
}

View File

@ -7,4 +7,8 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LiteDB" Version="5.0.21" />
</ItemGroup>
</Project>

View File

@ -1,25 +1,25 @@
@inherits LayoutComponentBase
<div class="page">
<nav class="navbar navbar-dark bg-dark sticky-top mb-4">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">📺 VideoStudy</span>
<div class="sidebar">
<NavMenu />
</div>
</nav>
<main role="main" class="px-4">
<main>
<div class="top-row px-4">
<a href="https://github.com/microsoft/semantic-kernel" target="_blank">About Semantic Kernel</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui">
<div class="alert alert-danger m-3" role="alert">
<h4 class="alert-heading">⚠️ Unhandled error</h4>
<p>An unhandled error has occurred. Please reload the page.</p>
<hr>
<button class="btn btn-primary reload">Reload</button>
<button class="btn btn-secondary dismiss">Dismiss</button>
</div>
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<style>
@ -27,32 +27,86 @@
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
}
main {
flex: 1;
}
#blazor-error-ui {
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.sidebar .top-row {
background-color: rgba(0,0,0,0.25);
border-bottom: none;
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.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;
position: fixed;
}
.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;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
align-items: center;
justify-content: center;
}
#blazor-error-ui[style*="display"] {
align-items: center;
justify-content: center;
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.reload, .dismiss {
cursor: pointer;
.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;
}
}
</style>

View File

@ -0,0 +1,97 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">VideoStudy</a>
</div>
</div>
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column p-2">
<div class="nav-item">
<NavLink class="nav-link custom-nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> <span class="nav-text">Início</span>
</NavLink>
</div>
<div class="nav-item">
<NavLink class="nav-link custom-nav-link" href="library">
<span class="bi bi-folder-fill-nav-menu" aria-hidden="true"></span> <span class="nav-text">Minhas Pastas</span>
</NavLink>
</div>
</nav>
</div>
<style>
/* Forçar visibilidade total ignorando heranças do Bootstrap */
.navbar-brand {
font-size: 1.3rem;
color: #ffffff !important;
font-weight: 700;
padding-left: 1rem;
}
.nav-item {
margin-bottom: 0.5rem;
}
/* Usando classe customizada para evitar colisões e garantir especificidade */
.custom-nav-link {
color: #ffffff !important;
background-color: transparent !important;
font-size: 1.1rem !important;
font-weight: 500 !important;
padding: 0.8rem 1.2rem !important;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
text-decoration: none !important;
opacity: 1 !important;
}
/* Forçar cor branca no texto interno explicitamente */
.custom-nav-link .nav-text {
color: #ffffff !important;
}
/* Ícones brancos puros */
.bi {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
display: inline-block;
background-size: cover;
filter: brightness(0) invert(1) !important;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.495v3.505a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5z'/%3E%3C/svg%3E");
}
.bi-folder-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-folder-fill' viewBox='0 0 16 16'%3E%3Cpath d='M9.828 3h3.982a2 2 0 0 1 1.992 2.181l-.637 7A2 2 0 0 1 13.174 14H2.825a2 2 0 0 1-1.991-1.819l-.637-7a1.99 1.99 0 0 1 .342-1.31L.5 3a2 2 0 0 1 2-2h3.672a2 2 0 0 1 1.414.586l.828.828A2 2 0 0 0 9.828 3zm-8.322.12C1.72 3.042 1.95 3 2.19 3h5.396l-.707-.707A1 1 0 0 0 6.172 2H2.5a1 1 0 0 0-1 .981l.006.139z'/%3E%3C/svg%3E");
}
/* Hover */
.custom-nav-link:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
}
/* Active */
.custom-nav-link.active {
background-color: #748ffc !important;
color: #ffffff !important;
font-weight: 600 !important;
}
@@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
display: block;
height: calc(100vh - 4rem);
overflow-y: auto;
padding-top: 1rem;
}
}
</style>

View File

@ -0,0 +1,149 @@
@page "/library"
@inject VideoStudy.Shared.Services.PersistenceService PersistenceService
@using VideoStudy.Shared
@using System.Diagnostics
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold text-dark">📚 Minha Biblioteca</h2>
@if (!string.IsNullOrEmpty(currentFolder))
{
<button class="btn btn-outline-secondary" @onclick="GoBack">
⬅️ Voltar
</button>
}
</div>
@if (string.IsNullOrEmpty(currentFolder))
{
<!-- MODO PASTAS -->
<div class="row g-3">
<!-- Pasta Geral (Padrão) -->
<div class="col-md-3">
<div class="card h-100 shadow-sm folder-card" @onclick='() => OpenFolder("Geral")'>
<div class="card-body text-center p-4">
<div class="display-1 mb-2">📁</div>
<h5 class="fw-bold">Geral</h5>
<small class="text-muted">@GetCount("Geral") arquivos</small>
</div>
</div>
</div>
<!-- Outras Pastas do Disco -->
@foreach (var folder in folders)
{
if (folder == "Geral") continue;
<div class="col-md-3">
<div class="card h-100 shadow-sm folder-card" @onclick='() => OpenFolder(folder)'>
<div class="card-body text-center p-4">
<div class="display-1 mb-2">📁</div>
<h5 class="fw-bold">@folder</h5>
<small class="text-muted">@GetCount(folder) arquivos</small>
</div>
</div>
</div>
}
</div>
}
else
{
<!-- MODO ARQUIVOS (DENTRO DA PASTA) -->
<h5 class="mb-3 text-muted">Pasta: <strong>@currentFolder</strong></h5>
<div class="list-group shadow-sm">
@foreach (var session in currentSessions)
{
<div class="list-group-item list-group-item-action d-flex align-items-center p-3">
<div class="me-3 fs-1">📄</div>
<div class="flex-grow-1">
<h6 class="mb-1 fw-bold">@session.Title</h6>
<small class="text-muted">
📅 @session.CreatedAt.ToString("g") •
<span class="text-truncate d-inline-block" style="max-width: 300px;">@session.FilePath</span>
</small>
</div>
<button class="btn btn-primary btn-sm px-4 fw-bold" @onclick="() => OpenPdf(session.FilePath)">
Abrir PDF ↗️
</button>
</div>
}
@if (!currentSessions.Any())
{
<div class="text-center p-5 text-muted">
<h5>Esta pasta está vazia 🕸️</h5>
</div>
}
</div>
}
</div>
<style>
.folder-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
border: none;
background: #f8f9fa;
}
.folder-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important;
background: #fff;
border: 1px solid #dee2e6;
}
</style>
@code {
private List<string> folders = new();
private List<VideoStudySession> allSessions = new();
private List<VideoStudySession> 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
}
}
}