diff --git a/Api/Api.http b/Api/Api.http index 3002a3b..f13ed0d 100644 --- a/Api/Api.http +++ b/Api/Api.http @@ -1,4 +1,4 @@ -@Api_HostAddress = http://projectblueprint.ru/ +@Api_HostAddress = 'http://localhost:63342' GET {{Api_HostAddress}}/ Accept: application/json diff --git a/Api/Application/Features/Project/DeleteLike/DeleteLikeHandler.cs b/Api/Application/Features/Project/DeleteLike/DeleteLikeHandler.cs new file mode 100644 index 0000000..5563fcd --- /dev/null +++ b/Api/Application/Features/Project/DeleteLike/DeleteLikeHandler.cs @@ -0,0 +1,13 @@ +namespace Api.Application.Features.Project.DeleteLike; +using Infrastructure.Repositories.Interfaces; +using MediatR; + +public class DeleteLikeHandler(IProjectRepository projectRepository) + : IRequestHandler +{ + public async Task Handle(DeleteLikeQuery request, CancellationToken cancellationToken) + { + var status = await projectRepository.UnlikeProjectAsync(request.Id,request.cookie.MetricUserId, cancellationToken); + return status; + } +} diff --git a/Api/Application/Features/Project/DeleteLike/DeleteLikeQuery.cs b/Api/Application/Features/Project/DeleteLike/DeleteLikeQuery.cs new file mode 100644 index 0000000..6d9a8cd --- /dev/null +++ b/Api/Application/Features/Project/DeleteLike/DeleteLikeQuery.cs @@ -0,0 +1,6 @@ +using Client.Models.Models.DTO; +using MediatR; + +namespace Api.Application.Features.Project.DeleteLike; + +public record DeleteLikeQuery(int Id, UserCookie cookie) : IRequest; diff --git a/Api/Application/Features/Project/GetProject/GetProjectHandle.cs b/Api/Application/Features/Project/GetProject/GetProjectHandle.cs index 5479811..5f9dfcb 100644 --- a/Api/Application/Features/Project/GetProject/GetProjectHandle.cs +++ b/Api/Application/Features/Project/GetProject/GetProjectHandle.cs @@ -9,7 +9,7 @@ public class GetProjectHandle(IProjectRepository projectRepository, IMetricRepos { public async Task Handle(GetProjectQuery request, CancellationToken cancellationToken) { - var project = await projectRepository.GetFullProjectInfoAsync(request.Id) ?? + var project = await projectRepository.GetFullProjectInfoAsync(request.Id, request.cookie.MetricUserId) ?? throw new KeyNotFoundException($"{request.Id}"); if (project is null) return null; @@ -27,4 +27,4 @@ await metricRepository.RegisterFilteredProjectViewAsync( cancellationToken); return project; } -} \ No newline at end of file +} diff --git a/Api/Application/Features/Project/GetProjects/GetProjectsHandle.cs b/Api/Application/Features/Project/GetProjects/GetProjectsHandle.cs index 6e86866..439f51c 100644 --- a/Api/Application/Features/Project/GetProjects/GetProjectsHandle.cs +++ b/Api/Application/Features/Project/GetProjects/GetProjectsHandle.cs @@ -17,7 +17,10 @@ await metricRepository.RegisterFilteredViewAsync(request.cookie.MetricUserId, request.filter.Page, occurredAtUtc, cancellationToken); - return await projectRepository.SearchAsync(request.filter, cancellationToken) + return await projectRepository.SearchAsync( + request.filter, + request.cookie.MetricUserId, + cancellationToken) ?? throw new KeyNotFoundException(); } } \ No newline at end of file diff --git a/Api/Application/Features/Project/ProjectController.cs b/Api/Application/Features/Project/ProjectController.cs index 15c466a..97c55dd 100644 --- a/Api/Application/Features/Project/ProjectController.cs +++ b/Api/Application/Features/Project/ProjectController.cs @@ -1,6 +1,8 @@ using Api.Application.Features.Project.GetProject; using Api.Application.Features.Project.GetProjects; using Api.Application.Features.Project.GetTags; +using Api.Application.Features.Project.DeleteLike; +using Api.Application.Features.Project.PutLike; using Client.Models.Models.DTO; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -24,6 +26,30 @@ public async Task GetProject(int id,[FromUserCookie] UserCookie c return Ok(result); } + [HttpPut("project/{id:int}/like")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task PutLike(int id,[FromUserCookie] UserCookie cookie) + { + var result = await mediator.Send(new PutLikeQuery(id,cookie)); + + if (result is false) + return NotFound(); + + return Ok(result); + } + [HttpDelete("project/{id:int}/like")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task DeleteLike(int id,[FromUserCookie] UserCookie cookie) + { + var result = await mediator.Send(new DeleteLikeQuery(id,cookie)); + + if (result is false) + return NotFound(); + + return Ok(result); + } [HttpGet("projects")] [ProducesResponseType(StatusCodes.Status200OK)] diff --git a/Api/Application/Features/Project/PutLike/PutLikeHandler.cs b/Api/Application/Features/Project/PutLike/PutLikeHandler.cs new file mode 100644 index 0000000..26856ce --- /dev/null +++ b/Api/Application/Features/Project/PutLike/PutLikeHandler.cs @@ -0,0 +1,18 @@ +namespace Api.Application.Features.Project.PutLike; +using Infrastructure.Repositories.Interfaces; +using MediatR; + +public class PutLikeHandler(IProjectRepository projectRepository) + : IRequestHandler +{ + public async Task Handle(PutLikeQuery request, CancellationToken cancellationToken) + { + var likedAtUtc = DateTime.UtcNow; + var status = await projectRepository.LikeProjectAsync( + request.Id, + request.cookie.MetricUserId, + likedAtUtc, + cancellationToken); + return status; + } +} diff --git a/Api/Application/Features/Project/PutLike/PutLikeQuery.cs b/Api/Application/Features/Project/PutLike/PutLikeQuery.cs new file mode 100644 index 0000000..bd786af --- /dev/null +++ b/Api/Application/Features/Project/PutLike/PutLikeQuery.cs @@ -0,0 +1,6 @@ +using Client.Models.Models.DTO; +using MediatR; + +namespace Api.Application.Features.Project.PutLike; + +public record PutLikeQuery(int Id, UserCookie cookie) : IRequest; \ No newline at end of file diff --git a/Api/Program.cs b/Api/Program.cs index 60154ae..91f7be5 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -20,6 +20,17 @@ options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; }); +builder.Services.AddCors(options => +{ + options.AddPolicy("FrontendDev", policy => + { + policy + .WithOrigins("http://localhost:63342") + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); +}); var app = builder.Build(); app.UseForwardedHeaders(); @@ -30,10 +41,9 @@ } app.InitializeDatabase(); -app.UseCors("FrontendDev"); app.UseDefaultFiles(); app.UseStaticFiles(); app.UseOpenTelemetryPrometheusScrapingEndpoint(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/Api/wwwroot/css/base.css b/Api/wwwroot/css/base.css index df7183f..4945267 100644 --- a/Api/wwwroot/css/base.css +++ b/Api/wwwroot/css/base.css @@ -15,19 +15,19 @@ --text: #ffffff; --text-muted: #C3C3C3; --accent: #9D6A69; + --border: #666; + --buttons: #161616; + --font-main: "Raleway", Arial, sans-serif; } - /* BODY */ body { padding-top: 70px; - background: var(--bg); color: var(--text); - font-family: Raleway, sans-serif; + font-family: var(--font-main); } - /* CONTAINER */ .container { diff --git a/Api/wwwroot/css/filters.css b/Api/wwwroot/css/filters.css index 514dd32..6741034 100644 --- a/Api/wwwroot/css/filters.css +++ b/Api/wwwroot/css/filters.css @@ -2,7 +2,9 @@ width: 100%; height: calc(100vh - 130px); min-height: 0; - background: #111; + position: sticky; + top: 110px; + background: transparent; border-radius: 20px; color: white; font-family: 'Raleway', sans-serif; @@ -18,21 +20,21 @@ gap: 16px; padding: 24px 24px 18px; flex-shrink: 0; - border-bottom: 1px solid rgba(255,255,255,0.06); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); } .filters h1 { font-size: 24px; font-weight: 700; margin: 0; - color: #fff; + color: var(--text); } .filters h2 { font-size: 20px; font-weight: 700; margin: 0; - color: #fff; + color: var(--text); } .filters-close { @@ -52,23 +54,18 @@ scrollbar-width: thin; } -.filter-section-title { - margin-top: 28px; - margin-bottom: 18px; -} - .filter-section-title:first-child { margin-top: 0; } .filter-subsection-title { - margin-top: 18px; - margin-bottom: 10px; + margin-top: 20px; + margin-bottom: 20px; } .filter-subsection-title h3 { font-size: 16px; - color: #fff; + color: var(--text); margin: 0; } @@ -87,7 +84,7 @@ gap: 12px; cursor: pointer; font-size: 16px; - color: #C3C3C3; + color: var(--text-muted); user-select: none; } @@ -96,7 +93,7 @@ -webkit-appearance: none; width: 18px; height: 18px; - border: 2px solid #666; + border: 2px solid var(--border); border-radius: 4px; background: transparent; cursor: pointer; @@ -105,8 +102,8 @@ } .filter-row input[type="checkbox"]:checked { - background: #9D6A69; - border-color: #9D6A69; + background: var(--accent); + border-color: var(--accent); } .filter-row input[type="checkbox"]:checked::after { @@ -116,7 +113,7 @@ top: 0; width: 5px; height: 10px; - border: solid #fff; + border: solid var(--text); border-width: 0 2px 2px 0; transform: rotate(45deg); } @@ -126,12 +123,54 @@ margin-top: 24px; min-height: 44px; border-radius: 14px; - border: 1px solid rgba(255,255,255,0.12); - background: #161616; - color: #fff; + border: 1px solid rgba(255, 255, 255, 0.12); + background: var(--buttons); + color: var(--text); font-size: 16px; cursor: pointer; } +.filter-subsection { + margin-bottom: 10px; +} + +.filter-subsection-title { + width: 100%; + margin-top: 20px; + margin-bottom: 20px; + padding: 0; + border: none; + background: transparent; + color: var(--text); + font: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + text-align: left; + flex-direction: row-reverse; +} + +.filter-subsection-title h3 { + font-size: 16px; + color: var(--text); + margin: 0; +} + +.filter-toggle-icon { + font-size: 18px; + line-height: 1; + transition: transform 0.2s ease; +} + +.filter-subsection.collapsed .filter-toggle-icon { + transform: rotate(-90deg); +} + +.filter-subsection.collapsed .filter-subsection-content { + display: none; +} + @media (max-width: 980px) { .filters { diff --git a/Api/wwwroot/css/footer.css b/Api/wwwroot/css/footer.css index 8f04f54..853a928 100644 --- a/Api/wwwroot/css/footer.css +++ b/Api/wwwroot/css/footer.css @@ -24,6 +24,7 @@ max-width: 640px; margin: 0 auto; } + .footer-title { font-size: 28px; font-weight: 600; @@ -56,11 +57,10 @@ border-radius: 20px; text-decoration: none; color: inherit; - transition: - transform 0.2s ease, - border-color 0.2s ease, - background 0.2s ease, - box-shadow 0.2s ease; + transition: transform 0.2s ease, + border-color 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; } .footer-link-card:hover { @@ -80,18 +80,18 @@ .footer-link-title { font-size: 20px; font-weight: 600; - color: #fff; + color: var(--text); } .footer-link-text { font-size: 14px; line-height: 1.45; - color: #C3C3C3; + color: var(--text-muted); } .footer-bottom { text-align: center; - padding-top: px; + padding-top: 30px; border-top: 1px solid rgba(255, 255, 255, 0.06); } diff --git a/Api/wwwroot/css/header.css b/Api/wwwroot/css/header.css index b2a0f32..cebd619 100644 --- a/Api/wwwroot/css/header.css +++ b/Api/wwwroot/css/header.css @@ -1,3 +1,4 @@ +/* HEADER */ .header { background: black; @@ -7,6 +8,7 @@ z-index: 1000; width: 100%; } + .header-row { display: flex; align-items: center; @@ -14,15 +16,12 @@ height: 100px; } +/* LOGO */ + .logo { display: flex; align-items: center; - gap: 59px; -} -.logo { - display: flex; - align-items: center; - gap: 59px; + gap: 30px; text-decoration: none; color: inherit; cursor: pointer; @@ -33,24 +32,27 @@ opacity: 0.9; transform: translateY(-1px); } + .logo img { - width: 52.51px; - height: 68.22px; + width: 53px; + height: 68px; } .logo-text { font-size: 25px; - color: #9D6A69; - font-weight: 50; + color: var(--accent); + font-weight: 500; } +/* NAVIGATIONS */ + .nav { display: flex; gap: 40px; } .nav button { - color: #C3C3C3; + color: var(--text-muted); text-decoration: none; font-size: 24px; border: none; @@ -75,10 +77,14 @@ height: 24px; } +/* MEDIAS */ + + @media (max-width: 768px) { .header { height: auto; } + .header-row { min-height: 100px; gap: 14px; @@ -101,6 +107,7 @@ gap: 8px; } } + @media (max-width: 520px) { .header-row { flex-wrap: nowrap; diff --git a/Api/wwwroot/css/linkOverlay.css b/Api/wwwroot/css/linkOverlay.css index 877ef4d..ff297cd 100644 --- a/Api/wwwroot/css/linkOverlay.css +++ b/Api/wwwroot/css/linkOverlay.css @@ -5,7 +5,7 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - background: white; + background: var(--text-muted); padding: 5% 6%; border-radius: 20px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); @@ -51,6 +51,7 @@ } .scroll-link-wrapper { + display: flex; overflow-y: auto; max-height: 200px; flex-wrap: wrap; @@ -111,7 +112,7 @@ margin-left: 15px; cursor: pointer; border: none; - background: #ffffff; + background: var(--text-muted); padding: 10px 18px; border-radius: 10px; font-size: 16px; @@ -120,5 +121,5 @@ } .copy-btn:hover { - background: #e0e0e0; + transform: scale(1.1); } \ No newline at end of file diff --git a/Api/wwwroot/css/project.css b/Api/wwwroot/css/project.css index 05dc610..ddf2d14 100644 --- a/Api/wwwroot/css/project.css +++ b/Api/wwwroot/css/project.css @@ -3,7 +3,7 @@ } .project-card { - background: #111; + background: var(--card); border-radius: 20px; padding: 40px; } @@ -31,10 +31,11 @@ text-align: center; word-break: break-word; } + .stack-empty { font-size: 24px; line-height: 1.4; - color: #c3c3c3; + color: var(--text-muted); min-height: auto; display: block; text-align: left; @@ -58,8 +59,8 @@ } .tab-btn.active { - background: #191919; - border-radius: 12549.8px; + background: var(--bg); + border-radius: 12550px; padding: 19px 16px; } @@ -112,7 +113,6 @@ } .table-caption { - text-align: center; margin-bottom: 1.5rem; font-size: 1.5rem; font-weight: 700; @@ -179,26 +179,6 @@ display: inline-block; } -@media (max-width: 480px) { - .table-wrapper { - padding: 1.2rem; - } - - .table-caption { - font-size: 1.2rem; - } - - .info-table th, - .info-table td { - padding: 12px 20px; - font-size: 0.9rem; - } - - .info-table td { - font-size: 1rem; - } -} - .icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 92px)); @@ -232,7 +212,6 @@ font-size: 0.85rem; color: #999; padding: 6px; - letter-spacing: 0.3px; opacity: 0; transform: translateY(-10px); transition: opacity 0.25s ease, transform 0.25s ease; @@ -245,6 +224,7 @@ opacity: 1; transform: translateY(0); } + .product-empty-state { display: flex; flex-direction: column; @@ -258,12 +238,12 @@ margin: 0; max-width: 420px; line-height: 1.5; - color: #ffffff; + color: var(--text); } .product-empty-state__text span { font-weight: 600; - color: #9D6A69; + color: var(--accent); } .product-empty-state__image { @@ -316,6 +296,26 @@ font-family: inherit; word-break: break-word; } +.project-header { + display: flex; + align-items: center; + gap: 30px; + width: fit-content; + margin: 0 auto; +} + +.project-like { + font-size: 40px; + margin-top: 0; + padding-bottom: 40px; + text-align: center; + +} + +.project-like .like-img { + width: 48px; + height: 48px; +} @media (max-width: 1100px) { .members-grid { @@ -324,9 +324,262 @@ } } -@media (max-width: 700px) { +@media (max-width: 768px) { + .members-grid { + grid-template-columns: 1fr; + gap: 24px; + } + + .project-title { + font-size: 48px; + margin-bottom: 36px; + } + + .tabs { + gap: 28px; + margin-bottom: 36px; + } + + .tab-btn { + font-size: 16px; + } + + .tab-btn.active { + padding: 14px 14px; + } + + .tab { + font-size: 21px; + gap: 36px; + } + + .stack-empty { + font-size: 20px; + } + + .product-empty-state__text { + font-size: 18px; + } + + .table-wrapper { + width: 100%; + } + + .info-table { + width: 100%; + min-width: 0; + overflow: hidden; + } + + .info-table th, + .info-table td { + padding: 12px 16px; + font-size: 0.95rem; + } + + .table-caption { + font-size: 1.25rem; + } +} + +@media (max-width: 560px) { + .members-grid { + grid-template-columns: 1fr; + gap: 24px; + } + + .project-page { + margin-top: 40px; + } + + .project-card { + padding: 24px 18px; + border-radius: 16px; + } + + .project-title { + font-size: 34px; + margin-bottom: 28px; + } + + .tabs { + overflow-x: auto; + padding-bottom: 6px; + } + + .tabs::-webkit-scrollbar { + display: none; + } + + .tab-btn { + flex: 0 0 auto; + } + .tab-btn.active { + padding: 12px 12px; + } + + .tab { + font-size: 17px; + line-height: 1.5; + gap: 28px; + } + .tab a { + word-break: break-all; + overflow-wrap: anywhere; + font-size: 15px; + } + + .stack-empty { + font-size: 16px; + min-width: 0; + } + + .project-stack { + margin-top: 28px; + } + + .product-empty-state__text { + font-size: 15px; + } + + .table-wrapper { + width: 100%; + overflow: visible; + } + + .info-table { + width: 100%; + min-width: 0; + table-layout: fixed; + border-collapse: collapse; + border-radius: 12px; + overflow: hidden; + } + + .info-table th, + .info-table td { + width: 50%; + padding: 10px 8px; + font-size: 0.82rem; + white-space: normal; + word-break: break-word; + overflow-wrap: anywhere; + border: 1px solid white; + border-radius: 12px; + } + + .info-table th { + width: 50%; + white-space: normal; + } + + .info-table td { + width: 50%; + font-size: 0.88rem; + } + + .table-caption { + font-size: 1.05rem; + margin-bottom: 1rem; + word-break: break-word; + + } + .project-header { + display: flex; + align-items: center; + gap: 30px; + width: fit-content; + margin: 0 auto; + } + + .project-like { + font-size: 20px; + margin-top: 0; + padding-bottom: 40px; + text-align: center; + + } + + .project-like .like-img { + width: 20px; + height: 20px; + } + +} + +@media (max-width: 400px) { .members-grid { grid-template-columns: 1fr; gap: 24px; } -} \ No newline at end of file + + .project-title { + font-size: 28px; + } + + .tab-btn { + font-size: 13px; + } + + .tab { + font-size: 15px; + } + + .info-table { + overflow: hidden; + } + + .stack-empty { + font-size: 15px; + } + + .product-empty-state__text { + font-size: 14px; + } + + .info-table th, + .info-table td { + padding: 8px 10px; + font-size: 0.78rem; + } + + .info-table td { + font-size: 0.82rem; + } + + .table-caption { + font-size: 0.95rem; + } + + .navigation-objs img { + width: 20px; + height: 20px; + } + + .navigation-objs a, + .navigation-objs button { + width: 36px; + height: 36px; + } + .project-header { + display: flex; + align-items: center; + gap: 10px; + width: fit-content; + margin: 0 auto; + } + + .project-like { + font-size: 20px; + margin-top: 0; + padding-bottom: 20px; + text-align: center; + + } + + .project-like .like-img { + width: 20px; + height: 20px; + } + +} diff --git a/Api/wwwroot/css/style.css b/Api/wwwroot/css/style.css index fb664a3..5add78a 100644 --- a/Api/wwwroot/css/style.css +++ b/Api/wwwroot/css/style.css @@ -5,9 +5,9 @@ } body { - background: #191919; - color: white; - font-family: Raleway, sans-serif; + background: var(--bg); + color: var(--text); + font-family: var(--font-main); } body.filters-open { @@ -23,14 +23,20 @@ body.filters-open { /* SEARCH */ .search-section { - margin-top: 0; - padding-top: 80px; + display: none; + margin: 0; + padding-top: 0; +} + +.search-section.active { + display: block; + padding-bottom: 60px; } .search-row { display: flex; - gap: 20px; align-items: center; + gap: 20px; flex-wrap: nowrap; } @@ -39,32 +45,35 @@ body.filters-open { align-items: center; justify-content: center; gap: 8px; - padding: 0 20px; min-height: 48px; - border-radius: 30px; + padding: 0 20px; border: none; - font-family: Raleway, sans-serif; + border-radius: 30px; + background: var(--card); + color: var(--text); + font-family: var(--font-main); font-size: 16px; - background: #111; - color: white; cursor: pointer; flex: 0 0 auto; white-space: nowrap; } - +.projects-grid--loading { + opacity: 0.55; + pointer-events: none; + transition: opacity 0.15s ease; +} .filter-btn img { flex-shrink: 0; } - .search { - min-height: 48px; - padding: 0 20px; display: flex; align-items: center; - background: white; - border-radius: 30px; flex: 1; min-width: 0; + min-height: 48px; + padding: 0 20px; + background: var(--text); + border-radius: 30px; } .search-icon { @@ -75,87 +84,38 @@ body.filters-open { .search label { display: flex; - flex: 1; align-items: center; + flex: 1; min-width: 0; height: 100%; } .search input { - border: none; - outline: none; - flex: 1; width: 100%; height: 100%; - font-size: 16px; - background: transparent; + flex: 1; min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 16px; } .clear { - cursor: pointer; - font-size: 20px; - color: gray; - border: none; - background: none; display: none; flex-shrink: 0; + border: none; + background: none; + color: gray; + font-size: 20px; + cursor: pointer; } .clear.active { display: block; } -/* PROJECTS */ - -.projects { - margin-top: 60px; -} - -.projects-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 40px; -} - -.card { - display: flex; - flex-direction: column; - background: #111; - border-radius: 16px; - padding: 25px; - min-height: 260px; - text-decoration: none; - color: inherit; - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); -} - -.card h1, -.card h3 { - font-size: 20px; - margin-bottom: 10px; -} - -.card p { - flex: 1; - margin-bottom: 14px; - font-size: 16px; - line-height: 1.4; - color: #d7d7d7; -} - -.stack { - margin-top: auto; -} - -.search-section { - padding-top: 0; -} +/* CATALOG */ .catalog-page { padding-top: 80px; @@ -165,7 +125,7 @@ body.filters-open { display: grid; grid-template-columns: 0 minmax(0, 1fr); gap: 0; - align-items: start; + align-items: stretch; transition: grid-template-columns 0.25s ease, gap 0.25s ease; } @@ -175,67 +135,160 @@ body.filters-open { } .catalog-sidebar { - min-width: 0; width: 0; + min-width: 0; overflow: hidden; opacity: 0; + align-self: stretch; + background: var(--card); + border-radius: 20px; transition: width 0.25s ease, opacity 0.2s ease; } .catalog-layout.filters-open .catalog-sidebar { width: 369px; opacity: 1; + overflow: visible; +} + +#filters-container { + width: 100%; + height: 100%; } .catalog-content { min-width: 0; } -.search-section { - padding-top: 0; +#filters-container { + width: 100%; + display: flex; } -@media (max-width: 980px) { - .catalog-layout, - .catalog-layout.filters-open { - grid-template-columns: 1fr; - gap: 0; - } +/* PROJECTS */ - .catalog-sidebar, - .catalog-layout.filters-open .catalog-sidebar { - width: 100%; - opacity: 1; - display: none; - } +.projects { + margin-top: 0; +} - .catalog-layout.filters-open .catalog-sidebar { - display: block; - margin-bottom: 24px; - } +.projects-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 40px; } -@media (max-width: 980px) { - .catalog-layout { - grid-template-columns: 1fr; - } +.search-stub { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + padding: 28px 0 12px; + text-align: center; +} - .catalog-sidebar { - position: static; - height: auto; - } +.search-stub__text { + margin: 0; + max-width: 480px; + font-size: 20px; + line-height: 1.5; + color: var(--text); +} + +.search-stub__text span { + color: var(--accent); + font-weight: 700; +} + +.search-stub__image { + width: 180px; + height: auto; + opacity: 0.95; + filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.12)); +} +.card { + display: flex; + position: relative; + flex-direction: column; + min-height: 260px; + padding: 25px; + background: var(--card); + border-radius: 16px; + color: inherit; + text-decoration: none; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card .like { + display: flex; + align-items: center; + gap: 2px; + position: absolute; + top: 17px; + right: 17px; } + +.card .like-btn { + background: none; + border: none; + cursor: pointer; + padding: 5px; + margin: 0; + display: flex; + align-items: center; + justify-content: center; +} + + +.card .like-img { + width: 30px; + height: 30px; + display: block; + background: none; +} + +.card .like-counter { + font-family: 'Raleway', sans-serif; + font-size: 20px; + font-weight: 500; + margin: 0; + line-height: 1; + color: #fff; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3); +} + +.card h1 { + margin-bottom: 10px; + font-size: 20px; +} + +.card p { + flex: 1; + margin-bottom: 14px; + color: #d7d7d7; + font-size: 16px; + line-height: 1.4; +} + +.stack { + margin-top: auto; +} + .stack-title { display: block; margin-bottom: 8px; - color: #c3c3c3; + color: var(--text-muted); } .icons { display: flex; + flex-wrap: wrap; gap: 8px; margin-top: 5px; - flex-wrap: wrap; } .icons img { @@ -261,39 +314,55 @@ body.filters-open { font-size: 14px; } -.projects-empty { +.projects-empty, +.projects-error { grid-column: 1 / -1; padding: 36px 0; - color: #c3c3c3; font-size: 18px; } +.projects-empty { + color: var(--text-muted); +} + .projects-error { - grid-column: 1 / -1; - padding: 36px 0; color: #ffb3b3; - font-size: 18px; } + +/* PAGINATION */ + .projects-pagination { - margin-top: 36px; display: flex; align-items: center; justify-content: center; gap: 16px; + margin-top: 36px; } .pagination-btn { + display: inline-flex; + align-items: center; + justify-content: center; width: 44px; height: 44px; + flex: 0 0 44px; + padding: 0; border: none; border-radius: 50%; - background: #111; - color: #fff; + background: var(--card); + color: var(--text); font-size: 22px; + line-height: 1; cursor: pointer; transition: transform 0.2s ease, opacity 0.2s ease, background 0.2s ease; } +.pagination-btn span { + display: block; + line-height: 1; + transform: translateY(-2.5px); +} + .pagination-btn:hover:not(:disabled) { transform: translateY(-1px); background: #181818; @@ -307,18 +376,41 @@ body.filters-open { .pagination-info { min-width: 140px; text-align: center; - color: #C3C3C3; + color: var(--text-muted); font-size: 15px; } +/* ADAPTIVE */ + +@media (max-width: 980px) { + .catalog-layout, + .catalog-layout.filters-open { + grid-template-columns: 1fr; + gap: 0; + } + + .catalog-sidebar, + .catalog-layout.filters-open .catalog-sidebar { + width: 100%; + opacity: 1; + display: none; + position: static; + height: auto; + } + + .catalog-layout.filters-open .catalog-sidebar { + display: block; + margin-bottom: 24px; + } +} + @media (max-width: 900px) { .projects-grid { grid-template-columns: repeat(2, 1fr); gap: 24px; } - .search-section { - margin-top: 0; + .search-section.active { padding-top: 60px; } } @@ -375,9 +467,9 @@ body.filters-open { } .filter-btn { - min-height: 44px; width: 44px; min-width: 44px; + min-height: 44px; padding: 0; gap: 0; border-radius: 50%; @@ -386,4 +478,47 @@ body.filters-open { .filter-btn span { display: none; } +} + +.like { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text); + font-weight: 700; + line-height: 1; + cursor: pointer; + user-select: none; + transition: opacity 0.2s ease, transform 0.2s ease; +} + + +.like-img { + width: 30px; + height: 30px; +} + +.like--active .like-img { + filter: invert(90%) sepia(88%) saturate(5201%) hue-rotate(339deg) brightness(70%) contrast(96%); +} + +.like:hover .like-img, +.like--active .like-img { + transform: scale(1.08); +} + +.like--loading { + opacity: 0.55; + pointer-events: none; +} + +.card .like { + position: absolute; + top: 10px; + right: 15px; + z-index: 2; +} + +.card .project-title { + padding-right: 66px; } \ No newline at end of file diff --git a/Api/wwwroot/index.html b/Api/wwwroot/index.html index fb5cf06..5d94b8d 100644 --- a/Api/wwwroot/index.html +++ b/Api/wwwroot/index.html @@ -3,6 +3,10 @@ Project Blueprint + + + + @@ -25,11 +29,6 @@
- -
@@ -77,12 +64,13 @@

- + + - + \ No newline at end of file diff --git a/Api/wwwroot/js/api/projectApi.js b/Api/wwwroot/js/api/projectApi.js index 3ed86db..d53b8c4 100644 --- a/Api/wwwroot/js/api/projectApi.js +++ b/Api/wwwroot/js/api/projectApi.js @@ -1,27 +1,54 @@ -console.log('projectApi.js loaded'); -console.log('project window.location.origin =', window.location.origin); - +const BASE_URL = 'http://localhost'; function buildUrl(path, params = new URLSearchParams()) { const query = params.toString(); return `${path}${query ? `?${query}` : ''}`; } -async function fetchJson(path, params = new URLSearchParams(), errorText = 'Request failed') { +async function requestJson(path, { + method = 'GET', + params = new URLSearchParams(), + body, + errorText = 'Request failed' +} = {}) { const url = buildUrl(path, params); - console.log('API FETCH =>', url); - - const response = await fetch(url, { + const options = { + method, + credentials: 'include', headers: { - Accept: '*/*' + Accept: 'application/json' } - }); + }; + + if (body !== undefined) { + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify(body); + } + + const response = await fetch(url, options); if (!response.ok) { - throw new Error(errorText); + throw new Error(`${errorText} (${response.status})`); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.includes('application/json')) { + return null; } return await response.json(); } +async function fetchJson(path, params = new URLSearchParams(), errorText = 'Request failed') { + return requestJson(path, { + params, + errorText + }); +} + export async function getProject(id) { return fetchJson( @@ -47,7 +74,6 @@ export async function getAllProjects(filters = {}) { if (filters.teamMemberCount != null) { params.append('TeamMemberCount', String(filters.teamMemberCount)); } - if (filters.year != null) { params.append('Year', String(filters.year)); } @@ -55,6 +81,13 @@ export async function getAllProjects(filters = {}) { if (filters.semester != null) { params.append('Semester', String(filters.semester)); } + if (filters.cookie?.metricUserId) { + params.append('cookie.metricUserId', filters.cookie.metricUserId); + } + + if (filters.cookie?.filterSessionId) { + params.append('cookie.filterSessionId', filters.cookie.filterSessionId); + } params.append('Page', String(filters.page ?? 1)); params.append('PageSize', String(filters.pageSize ?? 9)); @@ -66,10 +99,24 @@ export async function getAllProjects(filters = {}) { ); } +export async function likeProject(id) { + return requestJson(`/api/projects/project/${id}/like`, { + method: 'PUT', + errorText: 'Failed to like project' + }); +} + +export async function unlikeProject(id) { + return requestJson(`/api/projects/project/${id}/like`, { + method: 'DELETE', + errorText: 'Failed to unlike project' + }); +} + export async function getTags() { return fetchJson( '/api/projects/tags', new URLSearchParams(), 'Failed to fetch tags' ); -} \ No newline at end of file +} diff --git a/Api/wwwroot/js/catalog/catalogFilters.js b/Api/wwwroot/js/catalog/catalogFilters.js new file mode 100644 index 0000000..336ab0f --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogFilters.js @@ -0,0 +1,188 @@ +import {getTags} from "../api/projectApi.js"; + +import { + AVAILABLE_YEARS, + isFiltersInitialized, + markFiltersInitialized, + state +} from "./catalogState.js"; + +import { + syncUiWithState +} from "./catalogRender.js"; + +import { + refreshCatalog +} from "./catalogLoader.js"; + +function renderTagsFilters(tagGroups) { + const tagsContainer = document.getElementById("filter-tags-list"); + if (!tagsContainer) return; + + tagsContainer.innerHTML = ""; + + tagGroups.forEach((group) => { + if (!group || !Array.isArray(group.tags) || group.tags.length === 0) { + return; + } + + const section = document.createElement("div"); + section.className = "filter-group filter-subsection"; + + const title = document.createElement("button"); + title.type = "button"; + title.className = "filter-subsection-title"; + title.dataset.filterToggle = ""; + + const heading = document.createElement("h3"); + heading.textContent = group.type || "Теги"; + + const icon = document.createElement("img"); + icon.src = "resources/images/chevron.svg"; + icon.className = "filter-toggle-icon"; + icon.alt = ""; + + title.appendChild(icon); + title.appendChild(heading); + section.appendChild(title); + + const content = document.createElement("div"); + content.className = "filter-subsection-content"; + + group.tags.forEach((tag) => { + const row = document.createElement("div"); + row.className = "filter-row"; + + const label = document.createElement("label"); + const input = document.createElement("input"); + const text = document.createElement("span"); + + input.type = "checkbox"; + input.name = "tagIds"; + input.value = String(tag.id); + + text.textContent = tag.title || "Без названия"; + + label.appendChild(input); + label.appendChild(text); + row.appendChild(label); + content.appendChild(row); + }); + + section.appendChild(content); + tagsContainer.appendChild(section); + }); + + tagsContainer.querySelectorAll('input[name="tagIds"]').forEach((input) => { + input.addEventListener("change", () => { + state.tagIds = Array.from( + tagsContainer.querySelectorAll('input[name="tagIds"]:checked') + ).map((checkbox) => Number(checkbox.value)); + + state.page = 1; + refreshCatalog(); + }); + }); + + if (typeof window.initSearchAndFilter === "function") { + window.initSearchAndFilter(); + } + + syncUiWithState(); +} + +function renderYearFilters() { + const yearsContainer = document.getElementById("filter-years-list"); + if (!yearsContainer) return; + + yearsContainer.innerHTML = ""; + + AVAILABLE_YEARS.forEach((year) => { + const row = document.createElement("div"); + row.className = "filter-row"; + + const label = document.createElement("label"); + const input = document.createElement("input"); + const text = document.createElement("span"); + + input.type = "checkbox"; + input.name = "year"; + input.value = String(year); + text.textContent = String(year); + + label.appendChild(input); + label.appendChild(text); + row.appendChild(label); + yearsContainer.appendChild(row); + }); + + yearsContainer.querySelectorAll('input[name="year"]').forEach((input) => { + input.addEventListener("change", () => { + if (input.checked) { + yearsContainer.querySelectorAll('input[name="year"]').forEach((checkbox) => { + if (checkbox !== input) { + checkbox.checked = false; + } + }); + + state.year = Number(input.value); + } else { + state.year = null; + } + + state.page = 1; + refreshCatalog(); + }); + }); + + syncUiWithState(); +} + +function bindResetButton() { + const resetButton = document.getElementById("filters-reset"); + if (!resetButton || resetButton.dataset.bound === "true") return; + + resetButton.dataset.bound = "true"; + + resetButton.addEventListener("click", () => { + state.search = ""; + state.tagIds = []; + state.year = null; + state.page = 1; + + const searchInput = document.getElementById("search-input"); + if (searchInput) { + searchInput.value = ""; + searchInput.dispatchEvent(new Event("input", {bubbles: true})); + } + + document.querySelectorAll( + '#filter-tags-list input[type="checkbox"], #filter-years-list input[type="checkbox"]' + ).forEach((checkbox) => { + checkbox.checked = false; + }); + + refreshCatalog(); + }); +} + +export async function initFiltersUi() { + const tagsContainer = document.getElementById("filter-tags-list"); + const yearsContainer = document.getElementById("filter-years-list"); + + if (!tagsContainer || !yearsContainer || isFiltersInitialized()) return; + + markFiltersInitialized(); + + renderYearFilters(); + + try { + const groups = await getTags(); + renderTagsFilters(Array.isArray(groups) ? groups : []); + } catch (error) { + console.error("Error loading tags:", error); + tagsContainer.innerHTML = `
Не удалось загрузить теги.
`; + } + + bindResetButton(); +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogLoader.js b/Api/wwwroot/js/catalog/catalogLoader.js new file mode 100644 index 0000000..89a22e4 --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogLoader.js @@ -0,0 +1,94 @@ +import {getAllProjects} from "../api/projectApi.js"; + +import { + getLastRequestId, + getNextRequestId, + hasActiveFilters, + state +} from "./catalogState.js"; + +import { + saveCatalogState +} from "./catalogUrl.js"; + +import { + renderPagination, + renderProjects, + renderSearchStub +} from "./catalogRender.js"; + +export function refreshCatalog() { + saveCatalogState(); + loadProjects(); +} + +function syncFilterSessionId() { + if (typeof window.getOrCreateFilterSessionId !== "function") return; + + if (hasActiveFilters()) { + window.getOrCreateFilterSessionId(); + } else { + window.getOrCreateFilterSessionId(true); + } +} + +export async function loadProjects() { + const container = document.getElementById("projects-grid"); + const requestId = getNextRequestId(); + + if (container) { + container.classList.add("projects-grid--loading"); + + if (!container.children.length) { + container.innerHTML = `
Загрузка проектов...
`; + } + } + + try { + syncFilterSessionId(); + + const data = await getAllProjects({ + search: state.search, + tagIds: state.tagIds, + year: state.year, + page: state.page, + pageSize: state.pageSize + }); + + if (requestId !== getLastRequestId()) return; + + state.page = data.page ?? state.page; + state.totalPages = data.totalPages ?? 1; + state.totalCount = data.totalCount ?? 0; + + saveCatalogState(); + + const items = data.items || []; + + if (container) { + container.classList.remove("projects-grid--loading"); + } + + if (state.search && items.length === 0) { + renderSearchStub(state.search); + } else { + await renderProjects(items); + renderPagination(); + } + } catch (error) { + if (requestId !== getLastRequestId()) return; + + if (container) { + container.classList.remove("projects-grid--loading"); + } + + console.error("Error loading projects:", error); + + if (container) { + container.innerHTML = `
Не удалось загрузить проекты.
`; + } + + state.totalPages = 1; + renderPagination(); + } +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogPagination.js b/Api/wwwroot/js/catalog/catalogPagination.js new file mode 100644 index 0000000..4a4c304 --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogPagination.js @@ -0,0 +1,35 @@ +import {state} from "./catalogState.js"; +import {saveCatalogState} from "./catalogUrl.js"; +import {loadProjects} from "./catalogLoader.js"; +import {scrollToProjectsTop} from "./catalogRender.js"; + +export function bindPagination() { + const prevBtn = document.getElementById("pagination-prev"); + const nextBtn = document.getElementById("pagination-next"); + + if (prevBtn && prevBtn.dataset.bound !== "true") { + prevBtn.dataset.bound = "true"; + + prevBtn.addEventListener("click", async () => { + if (state.page <= 1) return; + + state.page -= 1; + saveCatalogState(); + await loadProjects(); + scrollToProjectsTop(); + }); + } + + if (nextBtn && nextBtn.dataset.bound !== "true") { + nextBtn.dataset.bound = "true"; + + nextBtn.addEventListener("click", async () => { + if (state.page >= state.totalPages) return; + + state.page += 1; + saveCatalogState(); + await loadProjects(); + scrollToProjectsTop(); + }); + } +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogRender.js b/Api/wwwroot/js/catalog/catalogRender.js new file mode 100644 index 0000000..f2ca40b --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogRender.js @@ -0,0 +1,221 @@ +import {state} from "./catalogState.js"; +import {initLikeElement, toggleProjectLike} from "../likes.js"; + +export function syncUiWithState() { + const searchInput = document.getElementById("search-input"); + if (searchInput) { + searchInput.value = state.search || ""; + } + + document.querySelectorAll('#filter-tags-list input[name="tagIds"]').forEach((checkbox) => { + checkbox.checked = state.tagIds.includes(Number(checkbox.value)); + }); + + document.querySelectorAll('#filter-years-list input[name="year"]').forEach((checkbox) => { + checkbox.checked = Number(checkbox.value) === state.year; + }); +} + +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function renderSearchStub(query) { + const container = document.getElementById("projects-grid"); + const pagination = document.getElementById("projects-pagination"); + + if (!container) return; + + container.innerHTML = ` +
+

+ По запросу ничего не найдено. +
+ Запрос: ${escapeHtml(query)} +

+ Ничего не найдено +
+ `; + + if (pagination) { + pagination.style.display = "none"; + } +} + +export function scrollToProjectsTop() { + const target = document.querySelector(".search-section") || document.querySelector(".projects"); + const headerOffset = 110; + + requestAnimationFrame(() => { + if (!target) { + window.scrollTo({ + top: 0, + behavior: "smooth" + }); + return; + } + + const top = target.getBoundingClientRect().top + window.scrollY - headerOffset; + + window.scrollTo({ + top: Math.max(top, 0), + behavior: "smooth" + }); + }); +} + +export function renderPagination() { + const pagination = document.getElementById("projects-pagination"); + const prevBtn = document.getElementById("pagination-prev"); + const nextBtn = document.getElementById("pagination-next"); + const info = document.getElementById("pagination-info"); + + if (!pagination || !prevBtn || !nextBtn || !info) return; + + const totalPages = Math.max(state.totalPages || 1, 1); + const currentPage = Math.min(state.page, totalPages); + + info.textContent = `Страница ${currentPage} из ${totalPages}`; + prevBtn.disabled = currentPage <= 1; + nextBtn.disabled = currentPage >= totalPages; + pagination.style.display = totalPages > 1 ? "flex" : "none"; +} + +function createTagChip(tag) { + if (tag.icon) { + const img = document.createElement("img"); + img.src = tag.icon; + img.alt = tag.title || "tag"; + img.title = tag.title || ""; + + if (tag.color) { + img.style.filter = "brightness(0) saturate(100%) invert(70%)"; + img.style.boxShadow = "none"; + } + + return img; + } + + const chip = document.createElement("span"); + chip.className = "tag-chip"; + chip.textContent = tag.title || "tag"; + return chip; +} +let projectCardTemplatePromise = null; + +async function getProjectCardTemplate() { + const existingTemplate = document.getElementById("project-card-template"); + if (existingTemplate) return existingTemplate; + + if (!projectCardTemplatePromise) { + const templateUrl = new URL("../../resources/components/card.html", import.meta.url); + projectCardTemplatePromise = fetch(templateUrl) + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load card template (${response.status})`); + } + + return response.text(); + }) + .then((html) => { + const wrapper = document.createElement("div"); + wrapper.innerHTML = html; + + const template = wrapper.querySelector("#project-card-template"); + + if (!template) { + throw new Error("Project card template not found in card.html"); + } + + document.body.appendChild(template); + return template; + }); + } + + return projectCardTemplatePromise; +} +export async function renderProjects(items) { + const container = document.getElementById("projects-grid"); + + if (!container) return; + + let template; + + try { + template = await getProjectCardTemplate(); + } catch (error) { + console.error("Error loading project card template:", error); + container.innerHTML = `
Не удалось загрузить шаблон карточки проекта.
`; + return; + } + + container.innerHTML = ""; + + if (!Array.isArray(items) || items.length === 0) { + container.innerHTML = `
По этим фильтрам пока ничего не найдено.
`; + return; + } + + items.forEach((project) => { + const clone = template.content.cloneNode(true); + const card = clone.querySelector(".card"); + const title = clone.querySelector(".project-title"); + const description = clone.querySelector(".project-description"); + const iconsContainer = clone.querySelector(".icons"); + const like = clone.querySelector(".like"); + + if (!card || !title || !description || !iconsContainer) { + return; + } + + card.href = `project.html?id=${project.id}`; + title.textContent = project.name || "Без названия"; + + initLikeElement(like, project); + like?.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + + try { + await toggleProjectLike(like, project.id); + } catch (error) { + console.error("Error toggling project like:", error); + } + }); + like?.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + + event.preventDefault(); + like.click(); + }); + description.textContent = + project.shortDescriptionAi || + "Описание пока не добавлено."; + + iconsContainer.innerHTML = ""; + + if (Array.isArray(project.tags) && project.tags.length > 0) { + project.tags.forEach((tag) => { + iconsContainer.appendChild(createTagChip(tag)); + }); + } else { + const empty = document.createElement("span"); + empty.className = "stack-empty"; + empty.textContent = "Стек не указан"; + iconsContainer.appendChild(empty); + } + + container.appendChild(clone); + }); +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogSearch.js b/Api/wwwroot/js/catalog/catalogSearch.js new file mode 100644 index 0000000..68144c2 --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogSearch.js @@ -0,0 +1,43 @@ +import {state} from "./catalogState.js"; +import {saveCatalogState} from "./catalogUrl.js"; +import {loadProjects, refreshCatalog} from "./catalogLoader.js"; + +export function clearCatalogSearch() { + state.search = ""; + state.page = 1; + + const searchInput = document.getElementById("search-input"); + if (searchInput) { + searchInput.value = ""; + } + + refreshCatalog(); +} + +export function bindSearch() { + const input = document.getElementById("search-input"); + if (!input || input.dataset.catalogSearchBound === "true") return; + + input.dataset.catalogSearchBound = "true"; + + input.addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + + event.preventDefault(); + + state.search = input.value.trim(); + state.page = 1; + + refreshCatalog(); + }); + + input.addEventListener("input", () => { + if (input.value.trim()) return; + if (!state.search) return; + + state.search = ""; + state.page = 1; + saveCatalogState(); + loadProjects(); + }); +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogState.js b/Api/wwwroot/js/catalog/catalogState.js new file mode 100644 index 0000000..60c3b27 --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogState.js @@ -0,0 +1,49 @@ +export const PAGE_SIZE = 9; +export const AVAILABLE_YEARS = [2021, 2022, 2023, 2024, 2025, 2026]; + +export const state = { + search: "", + tagIds: [], + year: null, + page: 1, + pageSize: PAGE_SIZE, + totalPages: 1, + totalCount: 0 +}; + +let baseInitialized = false; +let filtersInitialized = false; +let lastRequestId = 0; + +export function isBaseInitialized() { + return baseInitialized; +} + +export function markBaseInitialized() { + baseInitialized = true; +} + +export function isFiltersInitialized() { + return filtersInitialized; +} + +export function markFiltersInitialized() { + filtersInitialized = true; +} + +export function getNextRequestId() { + lastRequestId += 1; + return lastRequestId; +} + +export function getLastRequestId() { + return lastRequestId; +} + +export function hasActiveFilters() { + return Boolean( + state.search || + (Array.isArray(state.tagIds) && state.tagIds.length > 0) || + state.year + ); +} \ No newline at end of file diff --git a/Api/wwwroot/js/catalog/catalogUrl.js b/Api/wwwroot/js/catalog/catalogUrl.js new file mode 100644 index 0000000..b2cc8c6 --- /dev/null +++ b/Api/wwwroot/js/catalog/catalogUrl.js @@ -0,0 +1,57 @@ +import {PAGE_SIZE, state} from "./catalogState.js"; + +export function saveCatalogState() { + writeCatalogStateToUrl(); +} + +function getPositiveNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : fallback; +} + +export function readCatalogStateFromUrl() { + const params = new URLSearchParams(window.location.search); + + state.search = (params.get("search") || "").trim(); + + state.tagIds = params + .getAll("tagIds") + .flatMap((value) => value.split(",")) + .map(Number) + .filter(Number.isFinite); + + const year = params.get("year"); + state.year = year ? Number(year) : null; + + state.page = getPositiveNumber(params.get("page"), 1); + state.pageSize = getPositiveNumber(params.get("pageSize"), PAGE_SIZE); +} + +export function writeCatalogStateToUrl() { + const params = new URLSearchParams(); + + if (state.search) { + params.set("search", state.search); + } + + state.tagIds.forEach((tagId) => { + params.append("tagIds", String(tagId)); + }); + + if (state.year) { + params.set("year", String(state.year)); + } + + if (state.page > 1) { + params.set("page", String(state.page)); + } + + if (state.pageSize !== PAGE_SIZE) { + params.set("pageSize", String(state.pageSize)); + } + + const query = params.toString(); + const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`; + + history.replaceState(null, "", nextUrl); +} \ No newline at end of file diff --git a/Api/wwwroot/js/app.js b/Api/wwwroot/js/cookie.js similarity index 99% rename from Api/wwwroot/js/app.js rename to Api/wwwroot/js/cookie.js index ae0b798..c93deb0 100644 --- a/Api/wwwroot/js/app.js +++ b/Api/wwwroot/js/cookie.js @@ -48,4 +48,4 @@ function getOrCreateFilterSessionId(needReset = false) { document.addEventListener('DOMContentLoaded', () => { getOrCreateUserMetricId(); -}); \ No newline at end of file +}); diff --git a/Api/wwwroot/js/header.js b/Api/wwwroot/js/header.js index 199a8df..359c1db 100644 --- a/Api/wwwroot/js/header.js +++ b/Api/wwwroot/js/header.js @@ -1,6 +1,9 @@ async function loadHeader() { const headerContainer = document.getElementById('header'); - if (!headerContainer) return; + if (!headerContainer) { + console.error('Элемент #header не найден'); + return; + } const headerPaths = [ 'resources/components/header.html', @@ -39,25 +42,33 @@ function focusIndexSearch() { const searchSection = document.querySelector('.search-section'); const searchInput = document.getElementById('search-input'); - if (!searchInput) return; - - if (searchSection) { - searchSection.classList.add('active'); - searchSection.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (!searchSection) { + console.error('.search-section не найден'); + return; } - requestAnimationFrame(() => { - searchInput.focus(); - searchInput.select(); - }); + searchSection.classList.toggle('active'); + + if (searchSection.classList.contains('active')) { + searchSection.scrollIntoView({behavior: 'smooth', block: 'center'}); + + requestAnimationFrame(() => { + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + }); + } } function initHeaderSearch() { const searchBtn = document.getElementById('search-btn'); - - if (!searchBtn) return; + if (!searchBtn) { + console.error('#search-btn не найден'); + return; + } searchBtn.addEventListener('click', focusIndexSearch); } -loadHeader(); \ No newline at end of file +document.addEventListener('DOMContentLoaded', loadHeader); \ No newline at end of file diff --git a/Api/wwwroot/js/likes.js b/Api/wwwroot/js/likes.js new file mode 100644 index 0000000..f016a4b --- /dev/null +++ b/Api/wwwroot/js/likes.js @@ -0,0 +1,75 @@ +import {likeProject, unlikeProject} from "./api/projectApi.js"; + +const LIKE_COUNT_KEYS = ["likesCount", "likeCount", "likes", "likesAmount", "projectLikesCount"]; +const IS_LIKED_KEYS = ["isLiked", "liked", "hasLike", "isUserLiked", "likedByCurrentUser"]; + +function findFirstValue(source, keys) { + return keys + .map((key) => source?.[key]) + .find((value) => value != null); +} + +export function getProjectLikeState(project, fallback = { likesCount: 0, isLiked: false }) { + const rawLikesCount = findFirstValue(project, LIKE_COUNT_KEYS); + const rawIsLiked = findFirstValue(project, IS_LIKED_KEYS); + const likesCount = Number(rawLikesCount ?? fallback.likesCount); + + return { + likesCount: Number.isFinite(likesCount) ? likesCount : fallback.likesCount, + isLiked: rawIsLiked == null ? fallback.isLiked : Boolean(rawIsLiked) + }; +} + +function updateLikeElement(element, state) { + if (!element) return; + + const count = Math.max(Number(state.likesCount) || 0, 0); + const liked = Boolean(state.isLiked); + + element.classList.toggle("like--active", liked); + element.setAttribute("aria-pressed", String(liked)); + element.setAttribute("aria-label", liked ? "Убрать лайк" : "Поставить лайк"); + + const counter = element.querySelector(".like-counter"); + if (counter) { + counter.textContent = String(count); + } +} + +export function initLikeElement(element, project) { + if (!element) return; + + updateLikeElement(element, getProjectLikeState(project)); +} + +export async function toggleProjectLike(element, projectId) { + if (!element || !projectId || element.classList.contains("like--loading")) return; + + const wasLiked = element.classList.contains("like--active"); + const counter = element.querySelector(".like-counter"); + const currentCount = Number(counter?.textContent) || 0; + const optimisticState = { + isLiked: !wasLiked, + likesCount: currentCount + (wasLiked ? -1 : 1) + }; + + element.classList.add("like--loading"); + updateLikeElement(element, optimisticState); + + try { + const result = wasLiked + ? await unlikeProject(projectId) + : await likeProject(projectId); + + updateLikeElement(element, getProjectLikeState(result, optimisticState)); + } catch (error) { + updateLikeElement(element, { + isLiked: wasLiked, + likesCount: currentCount + }); + + throw error; + } finally { + element.classList.remove("like--loading"); + } +} \ No newline at end of file diff --git a/Api/wwwroot/js/linkOverlay.js b/Api/wwwroot/js/linkOverlay.js index 9a503c8..cd6a156 100644 --- a/Api/wwwroot/js/linkOverlay.js +++ b/Api/wwwroot/js/linkOverlay.js @@ -1,31 +1,116 @@ -function openOverlay() { - document.querySelector('.overlay').style.display = 'block'; - document.querySelector('.overlay-bg').style.display = 'block'; +function getBaseProjectLink() { + return `${window.location.origin}${window.location.pathname}${window.location.search}`; +} + +function getActiveTabId() { + const activeButton = document.querySelector(".tab-btn.active"); + if (activeButton?.dataset.tab) { + return activeButton.dataset.tab; + } + + const hashTab = window.location.hash.replace("#", ""); + return hashTab || "short"; +} + +function updateOverlayLinks() { + const fileLinkInput = document.getElementById("fileLink"); + const projectLinkInput = document.getElementById("projectLink"); + + if (!fileLinkInput || !projectLinkInput) return; + + const projectLink = getBaseProjectLink(); + const activeTab = getActiveTabId(); + const fileLink = `${projectLink}#${activeTab}`; + + fileLinkInput.value = fileLink; + projectLinkInput.value = projectLink; +} + +function openOverlay() { + updateOverlayLinks(); + + const overlay = document.querySelector(".overlay"); + const overlayBg = document.querySelector(".overlay-bg"); + + if (!overlay || !overlayBg) return; + + overlay.style.display = 'block'; + overlayBg.style.display = 'block'; document.body.style.overflow = 'hidden'; } function closeOverlay() { - document.querySelector('.overlay').style.display = 'none'; - document.querySelector('.overlay-bg').style.display = 'none'; + const overlay = document.querySelector('.overlay'); + const overlayBg = document.querySelector('.overlay-bg'); + + if (!overlay || !overlayBg) return; + + overlay.style.display = 'none'; + overlayBg.style.display = 'none'; document.body.style.overflow = ''; } function copyLink(id) { const input = document.getElementById(id); - input.select(); - input.setSelectionRange(0, 99999); + if (!input) return; + navigator.clipboard.writeText(input.value).then(() => { alert("Ссылка скопирована!"); }); } +function bindOverlayEvents(root = document) { + root.querySelectorAll('[data-close-overlay]').forEach((element) => { + if (element.dataset.bound === 'true') return; + + element.dataset.bound = 'true'; + element.addEventListener('click', closeOverlay); + }); + + root.querySelectorAll('[data-open-overlay]').forEach((button) => { + if (button.dataset.bound === 'true') return; + + button.dataset.bound = 'true'; + button.addEventListener('click', (event) => { + event.preventDefault(); + openOverlay(); + }); + }); + + root.querySelectorAll('[data-copy-link]').forEach((button) => { + if (button.dataset.bound === 'true') return; + + button.dataset.bound = 'true'; + button.addEventListener('click', () => { + copyLink(button.dataset.copyLink); + }); + }); +} +async function loadOverlay() { + if (document.querySelector(".overlay")) { + updateOverlayLinks(); + return; + } + + try { + const response = await fetch('linkOverlay.html'); + const html = await response.text(); + + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + bindOverlayEvents(container); + updateOverlayLinks(); + } catch (err) { + console.error('Ошибка загрузки оверлея:', err); + } +} + +window.openOverlay = openOverlay; +window.closeOverlay = closeOverlay; +window.copyLink = copyLink; +window.updateOverlayLinks = updateOverlayLinks; -document.addEventListener("DOMContentLoaded", function () { - fetch('linkOverlay.html') - .then(response => response.text()) - .then(html => { - const container = document.createElement('div'); - container.innerHTML = html; - document.body.appendChild(container); - }) - .catch(err => console.error('Ошибка загрузки оверлея:', err)); +document.addEventListener("DOMContentLoaded", () => { + bindOverlayEvents(); + loadOverlay(); }); \ No newline at end of file diff --git a/Api/wwwroot/js/project.js b/Api/wwwroot/js/project.js index e7e6d19..52738d3 100644 --- a/Api/wwwroot/js/project.js +++ b/Api/wwwroot/js/project.js @@ -1,45 +1,38 @@ import {getProject} from "./api/projectApi.js"; +import {initLikeElement, toggleProjectLike} from "./likes.js"; const params = new URLSearchParams(window.location.search); const projectId = params.get("id"); -function generateTabs(files) { - const container = document.getElementById("tabs-content"); - if (!container) return; - - container.querySelectorAll(".tab[data-generated='true']").forEach(tab => tab.remove()); - - Object.entries(files).forEach(([key, value]) => { - const tab = document.createElement("div"); - tab.className = "tab"; - tab.id = String(key).toLowerCase(); - tab.dataset.generated = "true"; - - if (isEmptyValue(value)) { - tab.innerHTML = getEmptyStateMarkup(key); - container.appendChild(tab); - return; - } - - if (Array.isArray(value)) { - tab.innerHTML = renderLinksList(key, value); - container.appendChild(tab); - return; - } - - tab.innerHTML = renderSingleLink(value); - container.appendChild(tab); - }); +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); } function isEmptyValue(value) { return value == null || (typeof value === "string" && value.trim() === ""); } + +function isValidUrl(value) { + if (typeof value !== "string") return false; + + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + function getEmptyStateMarkup(key) { return `

- У этого продукта, к сожалению, нет данных о ${escapeHtml(key)} + У этого проекта, к сожалению, нет данных о ${escapeHtml(key)}

{ + .map((link) => { const safeLink = escapeHtml(link); - return ` -
  • - - ${safeLink} - -
  • - `; + + if (isValidUrl(link)) { + return ` +
  • + + ${safeLink} + +
  • + `; + } + + return `
  • ${safeLink}
  • `; }) .join(""); return ` -

    Ссылки:

      ${items}
    `; } -function renderSingleLink(link) { - const safeLink = escapeHtml(link); - - return ` -

    Ссылка:

    -

    - - ${safeLink} - -

    - `; -} - -function escapeHtml(value) { - return String(value) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -async function loadProject() { - - if (!projectId) { - console.error("Project id not found in URL"); - return; +function renderSingleValue(key, value) { + if (isEmptyValue(value)) { + return getEmptyStateMarkup(key); } - try { - - const project = await getProject(projectId); + const safeValue = escapeHtml(value); - renderProject(project); + if (isValidUrl(value)) { + return ` +

    + + ${safeValue} + +

    + `; + } - } catch (error) { + return `

    ${safeValue.replaceAll("\n", "
    ")}

    `; +} - console.error("Error loading project:", error); +function generateTabs(files) { + const container = document.getElementById("tabs-content"); + if (!container) return; - const container = document.getElementById("project-container"); - if (container) { - container.innerHTML = "

    Не удалось загрузить проект

    "; - } + container.querySelectorAll(".tab[data-generated='true']").forEach((tab) => tab.remove()); + if (!files || typeof files !== "object") { + return; } -} -function renderStack(tags) { - const stackContainer = document.getElementById("icons"); - if (!stackContainer) return; + Object.entries(files).forEach(([key, value]) => { + const tab = document.createElement("div"); + tab.className = "tab"; + tab.id = String(key).toLowerCase(); + tab.dataset.generated = "true"; - stackContainer.innerHTML = ""; + if (Array.isArray(value)) { + tab.innerHTML = renderLinksList(key, value); + } else { + tab.innerHTML = renderSingleValue(key, value); + } - if (!Array.isArray(tags) || tags.length === 0) { - stackContainer.innerHTML = ` -

    Стек не указан

    - `; - return; - } - - for (const tag of tags) { - const backgroundStyle = tag.color - ? `style="background-color: ${tag.color};"` - : ""; - - stackContainer.innerHTML += `
    - ${tag.title} - ${formatTagName(tag.title)} -
    ` - } + container.appendChild(tab); + }); } function formatTagName(tag) { + const normalizedTag = String(tag || "").trim().toLowerCase(); + const labels = { python: "Python", csharp: "C#", @@ -181,66 +150,175 @@ function formatTagName(tag) { linux: "Linux" }; - return labels[tag] || tag.charAt(0).toUpperCase() + tag.slice(1); + if (labels[normalizedTag]) { + return labels[normalizedTag]; + } + + if (!normalizedTag) { + return "Без названия"; + } + + return normalizedTag.charAt(0).toUpperCase() + normalizedTag.slice(1); } + +function renderStack(tags) { + const stackContainer = document.getElementById("icons"); + if (!stackContainer) return; + + stackContainer.innerHTML = ""; + + if (!Array.isArray(tags) || tags.length === 0) { + stackContainer.innerHTML = `

    Стек не указан

    `; + return; + } + + tags.forEach((tag) => { + const item = document.createElement("div"); + item.className = "icon-item"; + + if (tag.icon) { + const img = document.createElement("img"); + img.src = tag.icon; + img.alt = tag.title || "Тег"; + + if (tag.color) { + img.style.filter = "brightness(0) saturate(100%) invert(70%)"; + img.style.boxShadow = "none"; + } + + item.appendChild(img); + } + + const label = document.createElement("span"); + label.className = "icon-label"; + label.textContent = formatTagName(tag.title); + item.appendChild(label); + + stackContainer.appendChild(item); + }); +} + +function renderMembers(teamMembers) { + const membersGrid = document.getElementById("members-grid"); + if (!membersGrid) return; + + const memberImages = [ + "resources/images/bow_blush.svg", + "resources/images/sad_tear.svg", + "resources/images/wink_star.svg", + "resources/images/sleepy_moon.svg", + "resources/images/sparkle_heart.svg", + "resources/images/glasses_sad.svg" + ]; + + if (!Array.isArray(teamMembers) || teamMembers.length === 0) { + membersGrid.innerHTML = `

    Участники не указаны

    `; + return; + } + + membersGrid.innerHTML = teamMembers + .map((member, index) => { + const memberName = escapeHtml(member?.userName || "Без имени"); + const avatar = memberImages[index % memberImages.length]; + + return ` +
    + Аватарка для ${memberName} + ${memberName} +
    + `; + }) + .join(""); +} + function renderProject(project) { - const title = document.getElementById("project-title"); const shortDescription = document.getElementById("short-description"); - const description = document.getElementById("description"); - const stackContainer = document.getElementById("stack-icons"); const year = document.getElementById("year"); const semester = document.getElementById("semester"); - const membersSection = document.querySelector(".members-section"); - const membersGrid = document.getElementById("members-grid"); - + const like = document.getElementById("project-like"); if (title) { - title.textContent = project.name; + title.textContent = project?.name || "Без названия"; } if (shortDescription) { - shortDescription.textContent = project.shortDescription || ""; - } - - if (description) { - description.textContent = project.description || ""; + shortDescription.textContent = + project?.descriptionAi || + project?.description || + ""; } if (year) { - year.textContent = project.year || "Год не указан"; + year.textContent = project?.year || "Год не указан"; } if (semester) { - semester.textContent = project.semester || ""; - } - if (membersSection && membersGrid) { - const members = project.teamMembers || []; - const memberImages = [ - "resources/images/bow_blush.svg", - "resources/images/sad_tear.svg", - "resources/images/wink_star.svg", - "resources/images/sleepy_moon.svg", - "resources/images/sparkle_heart.svg", - "resources/images/glasses_sad.svg", - ]; - - membersGrid.innerHTML = members.map((member, index) => { - const memberName = member.userName || "Без имени"; - const avatar = memberImages[index % memberImages.length]; + semester.textContent = project?.semester || "Семестр не указан"; + } - return ` -
    - Аватарка для ${memberName} - ${memberName} -
    - `; - }).join(""); + initLikeElement(like, project); + + renderMembers(project?.teamMembers); + renderStack(project?.tags); + generateTabs(project?.files); + + if (typeof window.activateTabFromHash === "function") { + window.activateTabFromHash(false); + } +} + +function bindProjectLike() { + const like = document.getElementById("project-like"); + + if (!like) return; + + like.addEventListener("click", async (event) => { + event.preventDefault(); + + try { + await toggleProjectLike(like, projectId); + } catch (error) { + console.error("Error toggling project like:", error); + } + }); + + like.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + + event.preventDefault(); + like.click(); + }); +} + +function renderProjectError() { + const title = document.getElementById("project-title"); + const shortDescription = document.getElementById("short-description"); + + if (title) { + title.textContent = "Не удалось загрузить проект"; + } + + if (shortDescription) { + shortDescription.textContent = "Попробуйте обновить страницу позже."; + } +} + +async function loadProject() { + if (!projectId) { + console.error("Project id not found in URL"); + renderProjectError(); + return; + } + + try { + const project = await getProject(projectId); + renderProject(project); + } catch (error) { + console.error("Error loading project:", error); + renderProjectError(); } - console.log(project.tags); - renderStack(project.tags); - - generateTabs(project.files); } +bindProjectLike(); loadProject(); \ No newline at end of file diff --git a/Api/wwwroot/js/projects.js b/Api/wwwroot/js/projects.js index 300c42c..3608cad 100644 --- a/Api/wwwroot/js/projects.js +++ b/Api/wwwroot/js/projects.js @@ -1,346 +1,62 @@ -import { getAllProjects, getTags } from "./api/projectApi.js"; +import { + isBaseInitialized, + markBaseInitialized +} from "./catalog/catalogState.js"; -const PAGE_SIZE = 9; -const AVAILABLE_YEARS = [2021, 2022, 2023, 2024, 2025, 2026]; -const state = { - search: '', - tagIds: [], - year: null, - page: 1, - pageSize: PAGE_SIZE, - totalPages: 1, - totalCount: 0 -}; +import { + readCatalogStateFromUrl +} from "./catalog/catalogUrl.js"; -let baseInitialized = false; -let filtersInitialized = false; -let searchDebounce = null; +import { + syncUiWithState +} from "./catalog/catalogRender.js"; -console.log('projects.js loaded'); -console.log('window.location.origin =', window.location.origin); -function renderPagination() { - const pagination = document.getElementById('projects-pagination'); - const prevBtn = document.getElementById('pagination-prev'); - const nextBtn = document.getElementById('pagination-next'); - const info = document.getElementById('pagination-info'); +import { + loadProjects, + refreshCatalog +} from "./catalog/catalogLoader.js"; - if (!pagination || !prevBtn || !nextBtn || !info) return; +import { + bindSearch, + clearCatalogSearch +} from "./catalog/catalogSearch.js"; - const totalPages = Math.max(state.totalPages || 1, 1); - const currentPage = Math.min(state.page, totalPages); +import { + bindPagination +} from "./catalog/catalogPagination.js"; - info.textContent = `Страница ${currentPage} из ${totalPages}`; +import { + initFiltersUi +} from "./catalog/catalogFilters.js"; - prevBtn.disabled = currentPage <= 1; - nextBtn.disabled = currentPage >= totalPages; - - pagination.style.display = totalPages > 1 ? 'flex' : 'none'; -} -function createTagChip(tag) { - if (tag.icon) { - const img = document.createElement('img'); - img.src = tag.icon; - img.alt = tag.title || 'tag'; - img.title = tag.title || ''; - if (tag.color) { - img.style.backgroundColor = tag.color; - } - return img; - } - - const chip = document.createElement('span'); - chip.className = 'tag-chip'; - chip.textContent = tag.title || 'tag'; - return chip; -} - -function renderProjects(items) { - const container = document.getElementById('projects-grid'); - const template = document.getElementById('project-card-template'); - - if (!container || !template) return; - - container.innerHTML = ''; - - if (!Array.isArray(items) || items.length === 0) { - container.innerHTML = `
    По этим фильтрам пока ничего не найдено.
    `; - return; - } - - items.forEach((project) => { - const clone = template.content.cloneNode(true); - const card = clone.querySelector('.card'); - const title = clone.querySelector('.project-title'); - const description = clone.querySelector('.project-description'); - const iconsContainer = clone.querySelector('.icons'); - - card.href = `project.html?id=${project.id}`; - title.textContent = project.name || 'Без названия'; - description.textContent = - project.shortDescriptionAi || - project.shortDescription || - 'Описание пока не добавлено.'; - - iconsContainer.innerHTML = ''; - - if (Array.isArray(project.tags) && project.tags.length > 0) { - project.tags.forEach((tag) => { - iconsContainer.appendChild(createTagChip(tag)); - }); - } else { - const empty = document.createElement('span'); - empty.className = 'stack-empty'; - empty.textContent = 'Стек не указан'; - iconsContainer.appendChild(empty); - } - - container.appendChild(clone); - }); -} -function hasActiveFilters() { - return Boolean( - state.search || - (Array.isArray(state.tagIds) && state.tagIds.length > 0) || - state.year - ); -} - -async function loadProjects() { - const container = document.getElementById('projects-grid'); - - if (container) { - container.innerHTML = `
    Загрузка проектов...
    `; - } - - try { - if (hasActiveFilters()) { - getOrCreateFilterSessionId(); - } else { - getOrCreateFilterSessionId(true); - } - - const data = await getAllProjects({ - search: state.search, - tagIds: state.tagIds, - year: state.year, - page: state.page, - pageSize: state.pageSize - }); - - state.page = data.page ?? state.page; - state.totalPages = data.totalPages ?? 1; - state.totalCount = data.totalCount ?? 0; - - renderProjects(data.items || []); - renderPagination(); - } catch (error) { - console.error('Error loading projects:', error); - - if (container) { - container.innerHTML = `
    Не удалось загрузить проекты.
    `; - } - - state.totalPages = 1; - renderPagination(); - } -} - -function bindPagination() { - const prevBtn = document.getElementById('pagination-prev'); - const nextBtn = document.getElementById('pagination-next'); - - if (prevBtn && prevBtn.dataset.bound !== 'true') { - prevBtn.dataset.bound = 'true'; - - prevBtn.addEventListener('click', () => { - if (state.page <= 1) return; - - state.page -= 1; - loadProjects(); - }); - } - - if (nextBtn && nextBtn.dataset.bound !== 'true') { - nextBtn.dataset.bound = 'true'; - - nextBtn.addEventListener('click', () => { - if (state.page >= state.totalPages) return; - - state.page += 1; - loadProjects(); - }); - } -} - -function renderTagsFilters(tagGroups) { - const tagsContainer = document.getElementById('filter-tags-list'); - if (!tagsContainer) return; - - tagsContainer.innerHTML = ''; - - tagGroups.forEach((group) => { - if (!group || !Array.isArray(group.tags) || group.tags.length === 0) { - return; - } - - const section = document.createElement('div'); - section.className = 'filter-group'; - - const title = document.createElement('div'); - title.className = 'filter-subsection-title'; - title.innerHTML = `

    ${group.type}

    `; - section.appendChild(title); - - group.tags.forEach((tag) => { - const row = document.createElement('div'); - row.className = 'filter-row'; - - row.innerHTML = ` - - `; - - section.appendChild(row); - }); - - tagsContainer.appendChild(section); - }); - - tagsContainer.querySelectorAll('input[name="tagIds"]').forEach((input) => { - input.addEventListener('change', () => { - state.tagIds = Array.from( - tagsContainer.querySelectorAll('input[name="tagIds"]:checked') - ).map((checkbox) => Number(checkbox.value)); - - state.page = 1; - loadProjects(); - }); - }); -} - -function renderYearFilters() { - const yearsContainer = document.getElementById('filter-years-list'); - if (!yearsContainer) return; - - yearsContainer.innerHTML = ''; - - AVAILABLE_YEARS.forEach((year) => { - const row = document.createElement('div'); - row.className = 'filter-row'; - - row.innerHTML = ` - - `; - - yearsContainer.appendChild(row); - }); - - yearsContainer.querySelectorAll('input[name="year"]').forEach((input) => { - input.addEventListener('change', () => { - if (input.checked) { - yearsContainer.querySelectorAll('input[name="year"]').forEach((checkbox) => { - if (checkbox !== input) { - checkbox.checked = false; - } - }); - - state.year = Number(input.value); - } else { - state.year = null; - } - - state.page = 1; - loadProjects(); - }); - }); -} - -function bindResetButton() { - const resetButton = document.getElementById('filters-reset'); - if (!resetButton || resetButton.dataset.bound === 'true') return; - - resetButton.dataset.bound = 'true'; - - resetButton.addEventListener('click', () => { - state.search = ''; - state.tagIds = []; - state.year = null; - state.page = 1; - - const searchInput = document.getElementById('search-input'); - if (searchInput) { - searchInput.value = ''; - searchInput.dispatchEvent(new Event('input', { bubbles: true })); - } - - document.querySelectorAll( - '#filter-tags-list input[type="checkbox"], #filter-years-list input[type="checkbox"]' - ).forEach((checkbox) => { - checkbox.checked = false; - }); - - loadProjects(); - }); -} - -async function initFiltersUi() { - const tagsContainer = document.getElementById('filter-tags-list'); - const yearsContainer = document.getElementById('filter-years-list'); - - if (!tagsContainer || !yearsContainer || filtersInitialized) return; - - filtersInitialized = true; - - renderYearFilters(); - - try { - const groups = await getTags(); - renderTagsFilters(Array.isArray(groups) ? groups : []); - } catch (error) { - console.error('Error loading tags:', error); - tagsContainer.innerHTML = `
    Не удалось загрузить теги.
    `; - } - - bindResetButton(); -} - -function bindSearch() { - const input = document.getElementById('search-input'); - if (!input || input.dataset.catalogSearchBound === 'true') return; - - input.dataset.catalogSearchBound = 'true'; - - input.addEventListener('input', () => { - clearTimeout(searchDebounce); - - searchDebounce = setTimeout(() => { - state.search = input.value.trim(); - state.page = 1; - loadProjects(); - }, 300); - }); -} +window.clearCatalogSearch = clearCatalogSearch; function initProjectCatalog() { - if (!baseInitialized) { - baseInitialized = true; + if (!isBaseInitialized()) { + markBaseInitialized(); + + readCatalogStateFromUrl(); + syncUiWithState(); bindSearch(); bindPagination(); - loadProjects(); + refreshCatalog(); } - initFiltersUi(); + initFiltersUi().then(() => { + syncUiWithState(); + }); } window.initProjectCatalog = initProjectCatalog; -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initProjectCatalog); +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initProjectCatalog); } else { initProjectCatalog(); -} \ No newline at end of file +} + +window.addEventListener("popstate", () => { + readCatalogStateFromUrl(); + syncUiWithState(); + loadProjects(); +}); \ No newline at end of file diff --git a/Api/wwwroot/js/searchAndFilterHandler.js b/Api/wwwroot/js/searchAndFilterHandler.js index 3356aed..a07670e 100644 --- a/Api/wwwroot/js/searchAndFilterHandler.js +++ b/Api/wwwroot/js/searchAndFilterHandler.js @@ -1,6 +1,6 @@ function initSearchAndFilter() { const input = document.getElementById('search-input'); - const clearBtn = document.getElementById('filters-close'); + const clearBtn = document.querySelector('.clear'); const layout = document.getElementById('catalog-layout'); if (input && clearBtn && input.dataset.searchInitialized !== 'true') { @@ -15,7 +15,13 @@ function initSearchAndFilter() { clearBtn.addEventListener('click', () => { input.value = ''; toggleClear(); - input.dispatchEvent(new Event('input', { bubbles: true })); + + if (typeof window.clearCatalogSearch === 'function') { + window.clearCatalogSearch(); + } else { + input.dispatchEvent(new Event('input', {bubbles: true})); + } + input.focus(); }); @@ -37,6 +43,19 @@ function initSearchAndFilter() { } }); } + + document.querySelectorAll('[data-filter-toggle]').forEach((button) => { + if (button.dataset.bound === 'true') return; + + button.dataset.bound = 'true'; + + button.addEventListener('click', () => { + const section = button.closest('.filter-subsection'); + if (!section) return; + + section.classList.toggle('collapsed'); + }); + }); } window.initSearchAndFilter = initSearchAndFilter; diff --git a/Api/wwwroot/js/tabs.js b/Api/wwwroot/js/tabs.js index d77b592..f0b307e 100644 --- a/Api/wwwroot/js/tabs.js +++ b/Api/wwwroot/js/tabs.js @@ -1,23 +1,48 @@ const buttons = document.querySelectorAll(".tab-btn"); -buttons.forEach(button => { +function activateTab(tabId, updateHash = true) { + const tabs = document.querySelectorAll(".tab"); - button.addEventListener("click", () => { + let targetId = tabId || "short"; + + const targetTab = document.getElementById(targetId); + const targetButton = document.querySelector(`.tab-btn[data-tab="${targetId}"]`); - const tabId = button.dataset.tab; + if (!targetTab || !targetButton) { + targetId = "short"; + } - const tabs = document.querySelectorAll(".tab"); + buttons.forEach(btn => { + btn.classList.toggle("active", btn.dataset.tab === targetId); + }); - buttons.forEach(btn => btn.classList.remove("active")); - tabs.forEach(tab => tab.classList.remove("active")); + document.querySelectorAll(".tab").forEach(tab => { + tab.classList.toggle("active", tab.id === targetId); + }); - button.classList.add("active"); + if (updateHash) { + history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${targetId}`); + } +} - const targetTab = document.getElementById(tabId); - if (targetTab) { - targetTab.classList.add("active"); - } +function activateTabFromHash(updateHash = false) { + const hash = window.location.hash.replace("#", "").trim(); + activateTab(hash || "short", updateHash); +} +buttons.forEach((button) => { + button.addEventListener("click", () => { + activateTab(button.dataset.tab, true); }); +}); + +document.addEventListener("DOMContentLoaded", () => { + activateTabFromHash(false); +}); + +window.addEventListener("hashchange", () => { + activateTabFromHash(false); +}); -}); \ No newline at end of file +window.activateTabFromHash = activateTabFromHash; +window.activateTab = activateTab; \ No newline at end of file diff --git a/Api/wwwroot/linkOverlay.html b/Api/wwwroot/linkOverlay.html index 7b71102..eb8310e 100644 --- a/Api/wwwroot/linkOverlay.html +++ b/Api/wwwroot/linkOverlay.html @@ -3,22 +3,27 @@ Link Overlay + + + + -
    +
    - × + ×

    Ссылка на файл:

    @@ -27,10 +32,11 @@

    Ссылка на файл:

    Ссылка на проект:

    diff --git a/Api/wwwroot/project.html b/Api/wwwroot/project.html index 4a9209f..01b7485 100644 --- a/Api/wwwroot/project.html +++ b/Api/wwwroot/project.html @@ -3,6 +3,12 @@ Проект + + + + + + @@ -40,6 +46,10 @@

    +
    @@ -112,14 +122,15 @@

    Участники:

    + + - - + \ No newline at end of file diff --git a/Api/wwwroot/resources/components/card.html b/Api/wwwroot/resources/components/card.html index 0f3cc8e..43f2cec 100644 --- a/Api/wwwroot/resources/components/card.html +++ b/Api/wwwroot/resources/components/card.html @@ -1,10 +1,19 @@  \ No newline at end of file diff --git a/Api/wwwroot/resources/components/filters.html b/Api/wwwroot/resources/components/filters.html index 6539377..74d21a1 100644 --- a/Api/wwwroot/resources/components/filters.html +++ b/Api/wwwroot/resources/components/filters.html @@ -1,19 +1,25 @@