diff --git a/assets/styles.css b/assets/styles.css index c3fc6b9..c985cb6 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -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; diff --git a/lib/controllers/likes.js b/lib/controllers/likes.js index 60e1375..475e590 100644 --- a/lib/controllers/likes.js +++ b/lib/controllers/likes.js @@ -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); diff --git a/locales/de.json b/locales/de.json index 49562d9..aa97160 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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." } diff --git a/locales/en.json b/locales/en.json index f1cb108..0634ffa 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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." } diff --git a/views/youtube-likes.njk b/views/youtube-likes.njk index eddd2b0..0d02164 100644 --- a/views/youtube-likes.njk +++ b/views/youtube-likes.njk @@ -1,53 +1,115 @@ {% extends "layouts/youtube.njk" %} {% block youtube %} -
{{ __("youtube.likes.description") }}
{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }} {% endif %} -+ {{ lastSync.synced }} {{ __("youtube.likes.newLikes") }}, + {{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }}, + {{ lastSync.total }} {{ __("youtube.likes.totalLikes") }} +
+ {% endif %} + {% endcall %} + + {# ── Sync controls ── #} {% call section({ title: __("youtube.likes.sync") }) %} + {% endcall %} - {% if lastSync %} -- {{ __("youtube.likes.lastSync") }}: - + {# ── Recent likes ── #} + {% call section({ title: __("youtube.likes.recentLikes") }) %} + {% if recentLikes and recentLikes.length > 0 %} +
+ {{ button({ href: mountPath + "/api/likes?limit=100", text: __("youtube.viewAll") + " (" + totalLikePosts + ")" }) }}
-- {{ lastSync.synced }} {{ __("youtube.likes.newLikes") }}, - {{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }}, - {{ lastSync.total }} {{ __("youtube.likes.totalLikes") }} -
-