feat: full likes dashboard with connection status, stats, recent likes

- Controller passes baseline status, seen count, recent like posts,
  total count, and flash messages from query params
- View uses Indiekit UI components (section, summary, prose, button,
  notificationBanner) for consistent look
- Recent likes list with thumbnails, titles, channel names
- Connection badge (connected/disconnected), sync controls
- Overview stats: seen videos, like posts, baseline status, last sync
- CSS for likes dashboard components
- Updated en/de locale strings with flash messages and new labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-18 21:54:28 +01:00
parent ab5380bf19
commit 6bd7966409
5 changed files with 278 additions and 31 deletions

View File

@@ -197,6 +197,110 @@
padding-block-start: var(--space-xl);
}
/* ── Likes dashboard ── */
/* Connection status */
.youtube-likes-connection {
align-items: center;
display: flex;
gap: var(--space-m);
}
.youtube-likes-badge {
align-items: center;
border-radius: 2rem;
display: inline-flex;
font-size: var(--step--1);
font-weight: var(--font-weight-semibold);
padding: var(--space-3xs) var(--space-s);
}
.youtube-likes-badge--connected {
background: #1a7f37;
color: white;
}
.youtube-likes-badge--disconnected {
background: var(--color-offset);
color: var(--color-text-secondary);
}
.youtube-likes-connection__action {
display: inline;
}
/* Sync result text */
.youtube-likes-sync-result {
color: var(--color-text-secondary);
font-size: var(--step--1);
margin-block-start: var(--space-xs);
}
/* Recent likes list */
.youtube-likes-list {
display: flex;
flex-direction: column;
gap: var(--space-s);
list-style: none;
margin: 0;
padding: 0;
}
.youtube-likes-item {
align-items: center;
background: var(--color-offset);
border-radius: var(--radius-m);
display: flex;
gap: var(--space-m);
padding: var(--space-s);
}
.youtube-likes-item__thumb-link {
flex-shrink: 0;
}
.youtube-likes-item__thumb {
border-radius: var(--radius-s);
height: 54px;
object-fit: cover;
width: 96px;
}
.youtube-likes-item__info {
flex: 1;
min-width: 0;
}
.youtube-likes-item__title {
display: -webkit-box;
font-size: var(--step--1);
font-weight: var(--font-weight-medium);
margin: 0 0 var(--space-3xs) 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.youtube-likes-item__title a {
color: inherit;
text-decoration: none;
}
.youtube-likes-item__title a:hover {
text-decoration: underline;
}
.youtube-likes-item__meta {
color: var(--color-text-secondary);
display: flex;
font-size: var(--step--2);
gap: var(--space-m);
}
.youtube-likes-more {
margin-block-start: var(--space-m);
}
/* Public link banner */
.youtube-public-link {
align-items: center;

View File

@@ -16,7 +16,7 @@ import { syncLikes, getLastSyncStatus } from "../likes-sync.js";
export const likesController = {
/**
* GET /likes — show OAuth status & synced likes info
* GET /likes — dashboard showing connection status, sync info, recent likes
*/
async get(request, response, next) {
try {
@@ -26,7 +26,7 @@ export const likesController = {
if (!db) {
return response.render("youtube-likes", {
title: response.locals.__("youtube.likes.title"),
error: { message: "Database not available" },
error: "Database not available",
});
}
@@ -34,19 +34,60 @@ export const likesController = {
if (!oauth?.clientId) {
return response.render("youtube-likes", {
title: response.locals.__("youtube.likes.title"),
error: { message: response.locals.__("youtube.likes.error.noOAuth") },
error: response.locals.__("youtube.likes.error.noOAuth"),
});
}
const tokens = await loadTokens(db);
const isConnected = Boolean(tokens?.refreshToken);
const lastSync = await getLastSyncStatus(db);
const baseline = await db.collection("youtubeMeta").findOne({ key: "likes_baseline" });
const seenCount = await db.collection("youtubeLikesSeen").countDocuments();
// Fetch recent like posts for the overview
let recentLikes = [];
let totalLikePosts = 0;
const postsCollection = request.app.locals.application.collections?.get("posts");
if (postsCollection) {
recentLikes = await postsCollection
.find({
"properties.post-type": "like",
"properties.youtube-video-id": { $exists: true },
})
.sort({ "properties.published": -1 })
.limit(10)
.toArray();
totalLikePosts = await postsCollection.countDocuments({
"properties.post-type": "like",
"properties.youtube-video-id": { $exists: true },
});
}
// Flash messages from query params
const { error: qError, connected, disconnected, synced, skipped } = request.query;
let success = null;
let notice = null;
if (connected) success = response.locals.__("youtube.likes.flash.connected");
if (disconnected) notice = response.locals.__("youtube.likes.flash.disconnected");
if (synced !== undefined) {
const s = parseInt(synced, 10) || 0;
const sk = parseInt(skipped, 10) || 0;
success = response.locals.__("youtube.likes.flash.synced", { synced: s, skipped: sk });
}
response.render("youtube-likes", {
title: response.locals.__("youtube.likes.title"),
isConnected,
lastSync,
baseline,
seenCount,
recentLikes: recentLikes.map((l) => l.properties),
totalLikePosts,
mountPath: request.baseUrl,
error: qError || null,
success,
notice,
});
} catch (error) {
console.error("[YouTube] Likes page error:", error);
@@ -162,6 +203,14 @@ export const likesController = {
return response.json(result);
}
if (result.error) {
return response.redirect(`${request.baseUrl}/likes?error=${encodeURIComponent(result.error)}`);
}
if (result.baselined) {
return response.redirect(`${request.baseUrl}/likes?synced=0&skipped=${result.baselined}`);
}
response.redirect(`${request.baseUrl}/likes?synced=${result.synced}&skipped=${result.skipped}`);
} catch (error) {
console.error("[YouTube] Manual sync error:", error);

View File

@@ -24,16 +24,32 @@
},
"likes": {
"title": "YouTube Likes",
"description": "Verbinde dein YouTube-Konto, um deine gelikten Videos als Like-Beiträge auf deinem Blog zu synchronisieren.",
"description": "Verbinde dein YouTube-Konto, um deine gelikten Videos als Like-Beiträge auf deinem Blog zu synchronisieren. Nur neue Likes (nach dem Verbinden) erzeugen Beiträge.",
"connect": "YouTube-Konto verbinden",
"connected": "Verbunden",
"notConnected": "Nicht verbunden",
"disconnect": "Trennen",
"sync": "Likes-Synchronisierung",
"sync": "Synchronisierung",
"syncNow": "Jetzt synchronisieren",
"lastSync": "Letzte Synchronisierung",
"newLikes": "neu",
"skippedLikes": "bereits synchronisiert",
"totalLikes": "insgesamt auf YouTube",
"status": "Verbindung",
"overview": "Übersicht",
"recentLikes": "Letzte Likes",
"noLikesYet": "Noch keine gelikten Videos synchronisiert. Neue Likes auf YouTube erscheinen hier nach der nächsten Synchronisierung.",
"baselineComplete": "Baseline abgeschlossen",
"baselinePending": "Baseline ausstehend — die erste Synchronisierung erfasst bestehende Likes, ohne Beiträge zu erzeugen.",
"baselinedAt": "Baseline erstellt am",
"seenVideos": "Videos gesehen",
"likePosts": "Like-Beiträge erstellt",
"viewOnYouTube": "Auf YouTube ansehen",
"flash": {
"connected": "YouTube-Konto erfolgreich verbunden. Starte eine Synchronisierung, um bestehende Likes zu erfassen.",
"disconnected": "YouTube-Konto getrennt.",
"synced": "Synchronisierung abgeschlossen: %{synced} neu, %{skipped} übersprungen."
},
"error": {
"noOAuth": "YouTube OAuth ist nicht konfiguriert. Setze YOUTUBE_OAUTH_CLIENT_ID und YOUTUBE_OAUTH_CLIENT_SECRET."
}

View File

@@ -24,16 +24,32 @@
},
"likes": {
"title": "YouTube Likes",
"description": "Connect your YouTube account to sync your liked videos as like posts on your blog.",
"description": "Connect your YouTube account to sync your liked videos as like posts on your blog. Only new likes (added after connecting) will create posts.",
"connect": "Connect YouTube Account",
"connected": "Connected",
"notConnected": "Not connected",
"disconnect": "Disconnect",
"sync": "Likes Sync",
"sync": "Sync",
"syncNow": "Sync Now",
"lastSync": "Last sync",
"newLikes": "new",
"skippedLikes": "already synced",
"totalLikes": "total on YouTube",
"status": "Connection",
"overview": "Overview",
"recentLikes": "Recent Likes",
"noLikesYet": "No liked videos synced yet. New likes you add on YouTube will appear here after the next sync.",
"baselineComplete": "Baseline complete",
"baselinePending": "Baseline pending — first sync will snapshot existing likes without creating posts.",
"baselinedAt": "Baselined at",
"seenVideos": "videos seen",
"likePosts": "like posts created",
"viewOnYouTube": "View on YouTube",
"flash": {
"connected": "YouTube account connected successfully. Run a sync to baseline your existing likes.",
"disconnected": "YouTube account disconnected.",
"synced": "Sync complete: %{synced} new, %{skipped} skipped."
},
"error": {
"noOAuth": "YouTube OAuth is not configured. Set YOUTUBE_OAUTH_CLIENT_ID and YOUTUBE_OAUTH_CLIENT_SECRET."
}

View File

@@ -1,53 +1,115 @@
{% extends "layouts/youtube.njk" %}
{% block youtube %}
<h2>{{ __("youtube.likes.title") }}</h2>
{# Flash messages via notificationBanner (error/success/notice come from controller) #}
{% if error %}
{{ prose({ text: error.message }) }}
{# Error state: no OAuth configured or DB unavailable #}
{{ notificationBanner({ type: "error", text: error }) }}
{% else %}
{# OAuth connection status #}
<div class="youtube-likes-status">
{# ── Connection status ── #}
{% call section({ title: __("youtube.likes.status") }) %}
{% if isConnected %}
<div class="youtube-likes-connected">
<span class="youtube-likes-status__badge youtube-likes-status__badge--connected">
<div class="youtube-likes-connection">
<span class="youtube-likes-badge youtube-likes-badge--connected">
{{ __("youtube.likes.connected") }}
</span>
<form method="post" action="{{ mountPath }}/likes/disconnect" style="display:inline;">
{{ button({ type: "submit", text: __("youtube.likes.disconnect"), classes: "button--secondary" }) }}
<form method="post" action="{{ mountPath }}/likes/disconnect" class="youtube-likes-connection__action">
{{ button({ type: "submit", text: __("youtube.likes.disconnect") }) }}
</form>
</div>
{% else %}
<div class="youtube-likes-connection">
<span class="youtube-likes-badge youtube-likes-badge--disconnected">
{{ __("youtube.likes.notConnected") }}
</span>
</div>
<p>{{ __("youtube.likes.description") }}</p>
{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }}
{% endif %}
</div>
{% endcall %}
{# Sync controls (only when connected) #}
{% if isConnected %}
<div class="youtube-likes-sync" style="margin-block-start: var(--space-l);">
{% call section({ title: __("youtube.likes.sync") }) %}
<form method="post" action="{{ mountPath }}/likes/sync">
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
</form>
{# ── Overview stats ── #}
{% call section({ title: __("youtube.likes.overview") }) %}
{{ summary({
rows: [
{
key: { text: __("youtube.likes.seenVideos") },
value: { text: seenCount | string }
},
{
key: { text: __("youtube.likes.likePosts") },
value: { text: totalLikePosts | string }
},
{
key: { text: __("youtube.likes.baselineComplete") if baseline else __("youtube.likes.baselinePending") | truncate(40) },
value: { text: baseline.completedAt if baseline else "—" }
},
{
key: { text: __("youtube.likes.lastSync") },
value: { text: lastSync.lastSyncAt if lastSync else "—" }
}
] | selectattr("key.text") | list
}) }}
{% if lastSync %}
<div class="youtube-likes-sync__info" style="margin-block-start: var(--space-s);">
<p>
{{ __("youtube.likes.lastSync") }}:
<time datetime="{{ lastSync.lastSyncAt }}">{{ lastSync.lastSyncAt }}</time>
</p>
<p>
<p class="youtube-likes-sync-result">
{{ lastSync.synced }} {{ __("youtube.likes.newLikes") }},
{{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }},
{{ lastSync.total }} {{ __("youtube.likes.totalLikes") }}
</p>
</div>
{% endif %}
{% endcall %}
</div>
{# ── Sync controls ── #}
{% call section({ title: __("youtube.likes.sync") }) %}
<form method="post" action="{{ mountPath }}/likes/sync">
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
</form>
{% endcall %}
{# ── Recent likes ── #}
{% call section({ title: __("youtube.likes.recentLikes") }) %}
{% if recentLikes and recentLikes.length > 0 %}
<ul class="youtube-likes-list">
{% for like in recentLikes %}
<li class="youtube-likes-item">
{% if like["youtube-thumbnail"] %}
<a href="{{ like['like-of'] }}" target="_blank" rel="noopener" class="youtube-likes-item__thumb-link">
<img src="{{ like['youtube-thumbnail'] }}" alt="" class="youtube-likes-item__thumb" loading="lazy">
</a>
{% endif %}
<div class="youtube-likes-item__info">
<h4 class="youtube-likes-item__title">
<a href="{{ like['like-of'] }}" target="_blank" rel="noopener">
{{ like.name }}
</a>
</h4>
<div class="youtube-likes-item__meta">
<span>{{ like["youtube-channel"] }}</span>
<time datetime="{{ like.published }}">{{ like.published }}</time>
</div>
</div>
</li>
{% endfor %}
</ul>
{% if totalLikePosts > 10 %}
<p class="youtube-likes-more">
{{ button({ href: mountPath + "/api/likes?limit=100", text: __("youtube.viewAll") + " (" + totalLikePosts + ")" }) }}
</p>
{% endif %}
{% else %}
{{ prose({ text: __("youtube.likes.noLikesYet") }) }}
{% endif %}
{% endcall %}
{% endif %}
{% endif %}
{% endblock %}