Commit: a6d85a9
Parent: cbc8226

Show clip details to the right

Mårten Åsberg committed on 2026-05-15 at 21:32
AGENTS.md +17 -3
diff --git a/AGENTS.md b/AGENTS.md
index e33d632..f851fc7 100644
@@ -122,6 +122,20 @@ Migrations run automatically on startup of `Api` and `Cli`.
### Testing
- Manual E2E tests use Playwright. Config is at `.playwright/cli.config.json`.
- To run E2E tests the frontend must be built into `src/Api/wwwroot/` and the Api must be running.
- Chrome/Chromium is the default for Playwright, if it fails try `--browser msedge`.
- Manual E2E tests use Playwright CLI (`@playwright/cli`).
- To run E2E tests the frontend must be built into `src/Api/wwwroot/` and the Api must be running on port 5055.
- Run from the project root:
```sh
pnpm -C src/Frontend build --emptyOutDir --outDir ../Api/wwwroot/
dotnet run --project src/Api/Api.csproj & # start in background
pnpm playwright-cli "<natural language instruction, e.g. navigate to http://localhost:5055 and take a screenshot>"
```
Consult the user with the following command
```sh
pnpm playwright-cli show --annotate
```
- Chromium (Chrome) may not be installed; in such cases try command again with `--browser msedge`.
src/Api/ApiEndpoints.cs +18 -2
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index 1cce2b9..c048e7c 100644
@@ -117,8 +117,24 @@ public static class ApiEndpoints
}
}
public sealed record Clip(Guid Id, TimeSpan Duration, DateTimeOffset CreatedAt, string? Caption, string[] Tags)
public sealed record Clip(
Guid Id,
TimeSpan Duration,
DateTimeOffset CreatedAt,
string? Caption,
string[] Tags,
ClipUpload[] Uploads
)
{
public static Clip FromDomain(Domain.Clip clip) =>
new(clip.Id, clip.Duration, clip.CreatedAt, clip.Caption, [.. clip.Tags.Select(t => t.Value)]);
new(
clip.Id,
clip.Duration,
clip.CreatedAt,
clip.Caption,
[.. clip.Tags.Select(t => t.Value)],
[.. clip.Uploads.Select(u => new ClipUpload(u.CanonicalUrl.ToString(), u.Platform, u.PublishedAt))]
);
}
public sealed record ClipUpload(string CanonicalUrl, string Platform, DateTimeOffset PublishedAt);
src/Frontend/src/Clip.ts +7 -0
diff --git a/src/Frontend/src/Clip.ts b/src/Frontend/src/Clip.ts
index 0e87b16..ddfd9f4 100644
@@ -1,7 +1,14 @@
export interface Upload {
canonicalUrl: string;
platform: string;
publishedAt: Date;
}
export interface Clip {
id: string;
duration: string;
createdAt: Date;
caption: string | null;
tags: Array<string>;
uploads: Array<Upload>;
}
src/Frontend/src/components/ClipDetails.vue +94 -0
diff --git a/src/Frontend/src/components/ClipDetails.vue b/src/Frontend/src/components/ClipDetails.vue
new file mode 100644
index 0000000..2fa6990
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { Clip } from "../Clip";
const { clip } = defineProps<{ clip: Clip }>();
function formatDuration(duration: string): string {
const parts = duration.split(":");
const hours = parseInt(parts[0]);
const minutes = parseInt(parts[1]);
const seconds = Math.floor(parseFloat(parts[2] ?? "0"));
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
function formatDate(date: Date): string {
return date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
}
</script>
<template>
<aside>
<h2 v-if="clip.caption">{{ clip.caption }}</h2>
<p v-if="clip.tags.length">
<template v-for="tag in clip.tags">
{{ " " }}<span>#{{ tag }}</span>
</template>
</p>
<dl>
<dt>Duration</dt>
<dd>{{ formatDuration(clip.duration) }}</dd>
<dt>Posted</dt>
<dd>{{ formatDate(clip.createdAt) }}</dd>
<template v-for="upload in clip.uploads" :key="upload.canonicalUrl">
<dt>{{ upload.platform }}</dt>
<dd><a :href="upload.canonicalUrl" rel="noopener noreferrer">{{ formatDate(upload.publishedAt) }}</a></dd>
</template>
</dl>
</aside>
</template>
<style scoped>
aside {
flex: 0 0 75vw;
height: 100dvh;
scroll-snap-align: end;
overflow-y: auto;
background: #111;
color: #fff;
padding: max(env(safe-area-inset-top, 0px), 2rem) 1.5rem env(safe-area-inset-bottom, 0px);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
h2 {
margin: 0;
font-size: 1.1rem;
line-height: 1.5;
}
p {
margin: 0;
}
p span {
color: #aaa;
font-size: 0.9rem;
}
dl {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
}
dt {
color: #888;
font-size: 0.9rem;
}
dd {
margin: 0;
font-size: 0.9rem;
text-align: right;
}
a {
color: inherit;
font-weight: 700;
}
</style>
src/Frontend/src/components/ClipItem.vue +10 -54
diff --git a/src/Frontend/src/components/ClipItem.vue b/src/Frontend/src/components/ClipItem.vue
index c8c01ec..f93beee 100644
@@ -1,75 +1,31 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { Clip } from "../Clip";
import ClipVideo from "./ClipVideo.vue";
import ClipDetails from "./ClipDetails.vue";
const { clip } = defineProps<{ clip: Clip }>();
const emit = defineEmits<{ visible: [] }>();
const videoEl = ref<HTMLVideoElement | null>(null);
let observer: IntersectionObserver | null = null;
onMounted(() => {
const video = videoEl.value!;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
emit("visible");
video.muted = false;
video.play().catch(() => {
video.muted = true;
video.play();
});
} else {
video.muted = true;
video.pause();
}
},
{ threshold: 0.5 }
);
observer.observe(video.parentElement!);
});
onUnmounted(() => observer?.disconnect());
</script>
<template>
<div class="clip">
<video ref="videoEl" :src="`/api/clips/${clip.id}/stream`" loop playsinline muted></video>
<div v-if="clip.caption" class="caption">
<p>
<span>{{ clip.caption }}</span>
<template v-for="tag in clip.tags">
{{ " " }}
<span>#{{ tag }}</span>
</template>
</p>
</div>
<ClipVideo :clip="clip" @visible="emit('visible')" />
<ClipDetails :clip="clip" />
</div>
</template>
<style scoped>
.clip {
position: relative;
display: flex;
height: 100dvh;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scroll-snap-align: start;
scroll-snap-stop: always;
scrollbar-width: none;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 1.2rem);
color: #ffffff;
line-height: 1.4;
.clip::-webkit-scrollbar {
display: none;
}
</style>
src/Frontend/src/components/ClipVideo.vue +99 -0
diff --git a/src/Frontend/src/components/ClipVideo.vue b/src/Frontend/src/components/ClipVideo.vue
new file mode 100644
index 0000000..6d021b0
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import type { Clip } from "../Clip";
const { clip } = defineProps<{ clip: Clip }>();
const emit = defineEmits<{ visible: [] }>();
const videoEl = ref<HTMLVideoElement | null>(null);
let observer: IntersectionObserver | null = null;
onMounted(() => {
const video = videoEl.value!;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
emit("visible");
video.muted = false;
video.play().catch(() => {
video.muted = true;
video.play();
});
} else {
video.muted = true;
video.pause();
}
},
{ threshold: 0.5 }
);
observer.observe(video);
});
onUnmounted(() => observer?.disconnect());
</script>
<template>
<div class="video-panel">
<video ref="videoEl" :src="`/api/clips/${clip.id}/stream`" loop playsinline muted></video>
<div class="caption">
<p v-if="clip.caption">
<span>{{ clip.caption }}</span>
<template v-for="tag in clip.tags">
{{ " " }}
<span>#{{ tag }}</span>
</template>
</p>
<span class="swipe-hint" aria-hidden="true">
<svg viewBox="0 0 6 12" width="6" height="12" fill="none"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="1,1 5,6 1,11"/>
</svg>
</span>
</div>
</div>
</template>
<style scoped>
.video-panel {
flex: 0 0 100vw;
height: 100dvh;
scroll-snap-align: start;
position: relative;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 1rem;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 1.2rem);
color: #ffffff;
line-height: 1.4;
display: flex;
align-items: flex-end;
gap: 0.75rem;
}
.caption p {
flex: 1 1 auto;
margin: 0;
}
.swipe-hint {
flex: 0 0 auto;
align-self: center;
color: rgba(255, 255, 255, 0.5);
display: flex;
align-items: center;
pointer-events: none;
}
</style>
src/Frontend/src/services/api.ts +1 -1
diff --git a/src/Frontend/src/services/api.ts b/src/Frontend/src/services/api.ts
index 572e8db..25dea19 100644
@@ -25,7 +25,7 @@ export async function fetchClips(after?: string): Promise<Array<Clip>> {
}
function reviveClips(key: string, value: any): any {
if (key === "createdAt" && typeof value === "string") {
if ((key === "createdAt" || key === "publishedAt") && typeof value === "string") {
return new Date(value);
}
return value;