diff --git a/src/Portfolio.Api/Program.cs b/src/Portfolio.Api/Program.cs
index e093629..cdef3e7 100644
--- a/src/Portfolio.Api/Program.cs
+++ b/src/Portfolio.Api/Program.cs
@@ -75,7 +75,6 @@ await using (var scope = app.Services.CreateAsyncScope()) {
await db.Database.EnsureCreatedAsync();
}
-app.UseHttpsRedirection();
app.MapDefaultEndpoints();
app.MapControllers();
diff --git a/src/Portfolio.Web/Components/App.razor b/src/Portfolio.Web/Components/App.razor
index 939e292..d0c615a 100644
--- a/src/Portfolio.Web/Components/App.razor
+++ b/src/Portfolio.Web/Components/App.razor
@@ -5,12 +5,15 @@
-
-
+
+
+
+
+
diff --git a/src/Portfolio.Web/Components/CancellableComponent.cs b/src/Portfolio.Web/Components/CancellableComponent.cs
new file mode 100644
index 0000000..a2aab92
--- /dev/null
+++ b/src/Portfolio.Web/Components/CancellableComponent.cs
@@ -0,0 +1,11 @@
+using Microsoft.AspNetCore.Components;
+
+namespace Portfolio.Web.Components;
+
+public class CancellableComponent : ComponentBase, IDisposable {
+ protected CancellationTokenSource TokenSource { get; } = new();
+
+ public void Dispose() {
+ TokenSource.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Components/Layout/FooterLinks.razor b/src/Portfolio.Web/Components/Layout/FooterLinks.razor
new file mode 100644
index 0000000..03ffba1
--- /dev/null
+++ b/src/Portfolio.Web/Components/Layout/FooterLinks.razor
@@ -0,0 +1,19 @@
+
diff --git a/src/Portfolio.Web/Components/Layout/FooterLinks.razor.css b/src/Portfolio.Web/Components/Layout/FooterLinks.razor.css
new file mode 100644
index 0000000..dff7425
--- /dev/null
+++ b/src/Portfolio.Web/Components/Layout/FooterLinks.razor.css
@@ -0,0 +1,30 @@
+footer {
+ display: flex;
+ margin-top: auto;
+ margin-bottom: 2rem;
+ padding-top: 5rem;
+ justify-content: space-between;
+ margin-inline: 12.5vw;
+}
+
+.title {
+ background: var(--gradient);
+ background-clip: text;
+ color: transparent;
+ user-select: none;
+ font-weight: 500;
+}
+
+a {
+ text-decoration: none;
+}
+
+.socials {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.socials a {
+ height: 25px;
+ display: flex;
+}
diff --git a/src/Portfolio.Web/Components/Layout/MainLayout.razor b/src/Portfolio.Web/Components/Layout/MainLayout.razor
index e3b2918..1b11967 100644
--- a/src/Portfolio.Web/Components/Layout/MainLayout.razor
+++ b/src/Portfolio.Web/Components/Layout/MainLayout.razor
@@ -1,23 +1,13 @@
@inherits LayoutComponentBase
-
-
+
+
-
-
-
-
- @Body
-
-
-
-
-
- An unhandled error has occurred.
-
Reload
-
🗙
-
\ No newline at end of file
+
+ @Body
+
+
+
+
\ No newline at end of file
diff --git a/src/Portfolio.Web/Components/Layout/MainLayout.razor.css b/src/Portfolio.Web/Components/Layout/MainLayout.razor.css
index 38d1f25..9e6e6ae 100644
--- a/src/Portfolio.Web/Components/Layout/MainLayout.razor.css
+++ b/src/Portfolio.Web/Components/Layout/MainLayout.razor.css
@@ -1,98 +1,17 @@
-.page {
- position: relative;
+main {
+ background-color: var(--background);
+ min-height: 100%;
display: flex;
flex-direction: column;
}
-main {
- flex: 1;
-}
-
-.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;
-}
-
- .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 {
- 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;
- }
-}
-
-#blazor-error-ui {
- color-scheme: light only;
- background: lightyellow;
- bottom: 0;
- box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
- box-sizing: border-box;
- display: none;
- left: 0;
- padding: 0.6rem 1.25rem 0.7rem 1.25rem;
- position: fixed;
+.nav-box {
width: 100%;
- z-index: 1000;
+ position: sticky;
+ top: 0;
}
- #blazor-error-ui .dismiss {
- cursor: pointer;
- position: absolute;
- right: 0.75rem;
- top: 0.5rem;
- }
+.content {
+ flex-grow: 1;
+ margin-inline: 12.5vw;
+}
diff --git a/src/Portfolio.Web/Components/Layout/NavMenu.razor b/src/Portfolio.Web/Components/Layout/NavMenu.razor
deleted file mode 100644
index 0b37b9d..0000000
--- a/src/Portfolio.Web/Components/Layout/NavMenu.razor
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Portfolio.Web/Components/Layout/NavMenu.razor.css b/src/Portfolio.Web/Components/Layout/NavMenu.razor.css
deleted file mode 100644
index a2aeace..0000000
--- a/src/Portfolio.Web/Components/Layout/NavMenu.razor.css
+++ /dev/null
@@ -1,105 +0,0 @@
-.navbar-toggler {
- appearance: none;
- cursor: pointer;
- width: 3.5rem;
- height: 2.5rem;
- color: white;
- position: absolute;
- top: 0.5rem;
- right: 1rem;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
-}
-
-.navbar-toggler:checked {
- background-color: rgba(255, 255, 255, 0.5);
-}
-
-.top-row {
- min-height: 3.5rem;
- background-color: rgba(0,0,0,0.4);
-}
-
-.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.5v3.5a.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-plus-square-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-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
-}
-
-.bi-list-nested-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-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%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 .nav-link {
- color: #d7d7d7;
- background: none;
- border: none;
- border-radius: 4px;
- height: 3rem;
- display: flex;
- align-items: center;
- line-height: 3rem;
- width: 100%;
- }
-
-.nav-item ::deep a.active {
- background-color: rgba(255,255,255,0.37);
- color: white;
-}
-
-.nav-item ::deep .nav-link:hover {
- background-color: rgba(255,255,255,0.1);
- color: white;
-}
-
-.nav-scrollable {
- display: none;
-}
-
-.navbar-toggler:checked ~ .nav-scrollable {
- display: block;
-}
-
-@media (min-width: 641px) {
- .navbar-toggler {
- display: none;
- }
-
- .nav-scrollable {
- /* Never collapse the sidebar for wide screens */
- display: block;
-
- /* Allow sidebar to scroll for tall menus */
- height: calc(100vh - 3.5rem);
- overflow-y: auto;
- }
-}
diff --git a/src/Portfolio.Web/Components/Layout/Navigation.razor b/src/Portfolio.Web/Components/Layout/Navigation.razor
new file mode 100644
index 0000000..f739cd2
--- /dev/null
+++ b/src/Portfolio.Web/Components/Layout/Navigation.razor
@@ -0,0 +1,25 @@
+
+
Leon Hoppe
+
+
+ Home
+ Projekte
+ Technologien
+ Über mich
+ Kontakt
+
+
+
+
diff --git a/src/Portfolio.Web/Components/Layout/Navigation.razor.css b/src/Portfolio.Web/Components/Layout/Navigation.razor.css
new file mode 100644
index 0000000..407a864
--- /dev/null
+++ b/src/Portfolio.Web/Components/Layout/Navigation.razor.css
@@ -0,0 +1,58 @@
+.navigation {
+ display: flex;
+ height: 50px;
+ justify-content: space-between;
+ align-items: center;
+ background-color: var(--background);
+ border-bottom: 1px solid var(--border-color);
+ padding-inline: 0.5rem;
+}
+
+.links {
+ display: flex;
+ gap: 1rem;
+}
+
+.navigation > a {
+ text-decoration: none;
+ font-size: 1.25rem;
+}
+
+.socials {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.socials a {
+ height: 25px;
+ display: flex;
+}
+
+.links ::deep a {
+ text-decoration: none;
+ position: relative;
+
+ &:before {
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 2px;
+ bottom: 0;
+ left: 0;
+ background-color: var(--text);
+ visibility: hidden;
+ transform: scaleX(0);
+ transform-origin: left;
+ transition: all 0.3s ease-in-out;
+ }
+}
+
+.links ::deep a.active::before {
+ visibility: visible;
+ transform: scaleX(1);
+}
+
+.links ::deep a:hover::before {
+ visibility: visible;
+ transform: scaleX(1);
+}
diff --git a/src/Portfolio.Web/Components/Pages/Counter.razor b/src/Portfolio.Web/Components/Pages/Counter.razor
deleted file mode 100644
index 15c11c3..0000000
--- a/src/Portfolio.Web/Components/Pages/Counter.razor
+++ /dev/null
@@ -1,19 +0,0 @@
-@page "/counter"
-@rendermode InteractiveServer
-
-Counter
-
-Counter
-
-Current count: @currentCount
-
-
-
-@code {
- private int currentCount = 0;
-
- private void IncrementCount() {
- currentCount++;
- }
-
-}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Components/Pages/Home.razor b/src/Portfolio.Web/Components/Pages/Home.razor
index dfcdf75..8902f34 100644
--- a/src/Portfolio.Web/Components/Pages/Home.razor
+++ b/src/Portfolio.Web/Components/Pages/Home.razor
@@ -1,6 +1,6 @@
@page "/"
-Home
+Portfolio von Leon Hoppe
Hello, world!
diff --git a/src/Portfolio.Web/Components/Pages/Weather.razor b/src/Portfolio.Web/Components/Pages/Weather.razor
deleted file mode 100644
index f8204c6..0000000
--- a/src/Portfolio.Web/Components/Pages/Weather.razor
+++ /dev/null
@@ -1,61 +0,0 @@
-@page "/weather"
-@attribute [StreamRendering]
-
-Weather
-
-Weather
-
-This component demonstrates showing data.
-
-@if (forecasts == null) {
-
- Loading...
-
-}
-else {
-
-
-
- | Date |
- Temp. (C) |
- Temp. (F) |
- Summary |
-
-
-
- @foreach (var forecast in forecasts) {
-
- | @forecast.Date.ToShortDateString() |
- @forecast.TemperatureC |
- @forecast.TemperatureF |
- @forecast.Summary |
-
- }
-
-
-}
-
-@code {
- private WeatherForecast[]? forecasts;
-
- protected override async Task OnInitializedAsync() {
- // Simulate asynchronous loading to demonstrate streaming rendering
- await Task.Delay(500);
-
- var startDate = DateOnly.FromDateTime(DateTime.Now);
- var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
- forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast {
- Date = startDate.AddDays(index),
- TemperatureC = Random.Shared.Next(-20, 55),
- Summary = summaries[Random.Shared.Next(summaries.Length)]
- }).ToArray();
- }
-
- private class WeatherForecast {
- public DateOnly Date { get; set; }
- public int TemperatureC { get; set; }
- public string? Summary { get; set; }
- public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
- }
-
-}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Components/Routes.razor b/src/Portfolio.Web/Components/Routes.razor
index ae94e9e..090a0f6 100644
--- a/src/Portfolio.Web/Components/Routes.razor
+++ b/src/Portfolio.Web/Components/Routes.razor
@@ -1,6 +1,5 @@
-
\ No newline at end of file
diff --git a/src/Portfolio.Web/Program.cs b/src/Portfolio.Web/Program.cs
index d82cbbf..6ab644c 100644
--- a/src/Portfolio.Web/Program.cs
+++ b/src/Portfolio.Web/Program.cs
@@ -1,4 +1,6 @@
+using Portfolio.Shared.Services;
using Portfolio.Web.Components;
+using Portfolio.Web.Services;
var builder = WebApplication.CreateBuilder(args);
@@ -8,6 +10,15 @@ builder.Services.AddRazorComponents()
builder.AddServiceDefaults();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+
+builder.Services.AddHttpClient("api", client => {
+ client.BaseAddress = new Uri("http://api");
+});
+
var app = builder.Build();
// Configure the HTTP request pipeline.
@@ -17,7 +28,6 @@ if (!app.Environment.IsDevelopment()) {
app.UseHsts();
}
-app.UseHttpsRedirection();
app.MapDefaultEndpoints();
app.UseAntiforgery();
diff --git a/src/Portfolio.Web/Services/AboutRepository.cs b/src/Portfolio.Web/Services/AboutRepository.cs
new file mode 100644
index 0000000..e666782
--- /dev/null
+++ b/src/Portfolio.Web/Services/AboutRepository.cs
@@ -0,0 +1,20 @@
+using Portfolio.Shared.Models;
+using Portfolio.Shared.Services;
+
+namespace Portfolio.Web.Services;
+
+internal sealed class AboutRepository(IHttpClientFactory factory) : IAboutRepository {
+ private About DefaultValue => new() {
+ AboutMe = string.Empty,
+ Future = string.Empty
+ };
+
+ public async Task GetAbout(CancellationToken ct) {
+ var client = factory.CreateClient("api");
+ var response = await client.GetAsync("api/about", ct);
+ if (!response.IsSuccessStatusCode) return DefaultValue;
+
+ var data = await response.Content.ReadFromJsonAsync(ct);
+ return data ?? DefaultValue;
+ }
+}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Services/ProjectRepository.cs b/src/Portfolio.Web/Services/ProjectRepository.cs
new file mode 100644
index 0000000..eccefc4
--- /dev/null
+++ b/src/Portfolio.Web/Services/ProjectRepository.cs
@@ -0,0 +1,15 @@
+using Portfolio.Shared.Models;
+using Portfolio.Shared.Services;
+
+namespace Portfolio.Web.Services;
+
+internal sealed class ProjectRepository(IHttpClientFactory factory) : IProjectRepository {
+ public async Task> GetProjects(CancellationToken ct) {
+ var client = factory.CreateClient("api");
+ var response = await client.GetAsync("api/projects", ct);
+ if (!response.IsSuccessStatusCode) return [];
+
+ var data = await response.Content.ReadFromJsonAsync>(ct);
+ return data ?? [];
+ }
+}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Services/TechnologyRepository.cs b/src/Portfolio.Web/Services/TechnologyRepository.cs
new file mode 100644
index 0000000..d723a6d
--- /dev/null
+++ b/src/Portfolio.Web/Services/TechnologyRepository.cs
@@ -0,0 +1,15 @@
+using Portfolio.Shared.Models;
+using Portfolio.Shared.Services;
+
+namespace Portfolio.Web.Services;
+
+internal sealed class TechnologyRepository(IHttpClientFactory factory) : ITechnologyRepository {
+ public async Task> GetTechnologies(CancellationToken ct) {
+ var client = factory.CreateClient("api");
+ var response = await client.GetAsync("api/technologies", ct);
+ if (!response.IsSuccessStatusCode) return [];
+
+ var data = await response.Content.ReadFromJsonAsync>(ct);
+ return data ?? [];
+ }
+}
\ No newline at end of file
diff --git a/src/Portfolio.Web/Services/TimelineRepository.cs b/src/Portfolio.Web/Services/TimelineRepository.cs
new file mode 100644
index 0000000..0057e61
--- /dev/null
+++ b/src/Portfolio.Web/Services/TimelineRepository.cs
@@ -0,0 +1,15 @@
+using Portfolio.Shared.Models;
+using Portfolio.Shared.Services;
+
+namespace Portfolio.Web.Services;
+
+internal sealed class TimelineRepository(IHttpClientFactory factory) : ITimelineRepository {
+ public async Task> GetTimeline(TimelineEntryType type, CancellationToken ct) {
+ var client = factory.CreateClient("api");
+ var result = await client.GetAsync($"api/timeline/{type}", ct);
+ if (!result.IsSuccessStatusCode) return [];
+
+ var data = await result.Content.ReadFromJsonAsync>(ct);
+ return data ?? [];
+ }
+}
\ No newline at end of file
diff --git a/src/Portfolio.Web/wwwroot/app.css b/src/Portfolio.Web/wwwroot/app.css
index e69de29..e9aa8a2 100644
--- a/src/Portfolio.Web/wwwroot/app.css
+++ b/src/Portfolio.Web/wwwroot/app.css
@@ -0,0 +1,32 @@
+:root {
+ --primary: #8e5bd2;
+ --secondary: #11a8bd;
+
+ --gradient: linear-gradient(90deg, var(--primary), var(--secondary));
+ --gradient-angled: linear-gradient(135deg, var(--primary), var(--secondary));
+ --gradient-straight: linear-gradient(var(--primary), var(--secondary));
+
+ --padding: 12.5vw;
+ --padding-small: 5vw;
+ --mobile-width: 750px;
+
+ --desc-color: #7c8393;
+ --border-color: #2d2d2d;
+
+ --text: #ffffff;
+ --background: #0f1724;
+}
+
+* {
+ box-sizing: border-box;
+ color: var(--text);
+ font-family: "Ubuntu", serif;
+ font-weight: 400;
+ font-style: normal;
+}
+
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
diff --git a/src/Portfolio.Web/wwwroot/favicon.ico b/src/Portfolio.Web/wwwroot/favicon.ico
new file mode 100644
index 0000000..0073cd5
Binary files /dev/null and b/src/Portfolio.Web/wwwroot/favicon.ico differ
diff --git a/src/Portfolio.Web/wwwroot/favicon.png b/src/Portfolio.Web/wwwroot/favicon.png
deleted file mode 100644
index a1dc44a..0000000
--- a/src/Portfolio.Web/wwwroot/favicon.png
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:e265ac0f2dda1e5dfa65b1adf330722bb3ef7789115283604d8cd19f098f1f08
-size 1148
diff --git a/src/Portfolio.Web/wwwroot/socials/gitlab.png b/src/Portfolio.Web/wwwroot/socials/gitlab.png
new file mode 100644
index 0000000..a294eb8
--- /dev/null
+++ b/src/Portfolio.Web/wwwroot/socials/gitlab.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72a2cad5025aa931d6ea56c3201d1f18e68a8cd39788c7c80d5b2b82aa5143ef
+size 591
diff --git a/src/Portfolio.Web/wwwroot/socials/instagram.png b/src/Portfolio.Web/wwwroot/socials/instagram.png
new file mode 100644
index 0000000..473e499
--- /dev/null
+++ b/src/Portfolio.Web/wwwroot/socials/instagram.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:fdc2ac0085453fedb24be138132b4858add40ec998259ae94fafb9decd459e69
+size 1772
diff --git a/src/Portfolio.Web/wwwroot/socials/mail.png b/src/Portfolio.Web/wwwroot/socials/mail.png
new file mode 100644
index 0000000..f0cb6ba
--- /dev/null
+++ b/src/Portfolio.Web/wwwroot/socials/mail.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:407b558182f98f948d045cd6b4ea62bec3b8a8322cada63b2b07a7ea12393ffe
+size 1459