feat: versão com menu
This commit is contained in:
parent
7417f1f6c6
commit
a709d4eae3
@ -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();
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>();
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
101
VideoStudy.Shared/Services/PersistenceService.cs
Normal file
101
VideoStudy.Shared/Services/PersistenceService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -7,4 +7,8 @@
|
||||
<Platforms>AnyCPU;x64</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LiteDB" Version="5.0.21" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -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>
|
||||
97
VideoStudy.UI/Layout/NavMenu.razor
Normal file
97
VideoStudy.UI/Layout/NavMenu.razor
Normal 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>
|
||||
149
VideoStudy.UI/Pages/Library.razor
Normal file
149
VideoStudy.UI/Pages/Library.razor
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user