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.title") }}

+ + {# 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 #} -
+ {# ── Connection status ── #} + {% call section({ title: __("youtube.likes.status") }) %} {% if isConnected %} -
- +
+ {{ __("youtube.likes.connected") }} -
- {{ button({ type: "submit", text: __("youtube.likes.disconnect"), classes: "button--secondary" }) }} + + {{ button({ type: "submit", text: __("youtube.likes.disconnect") }) }}
{% else %} +
+ + {{ __("youtube.likes.notConnected") }} + +

{{ __("youtube.likes.description") }}

{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }} {% endif %} -
+ {% endcall %} - {# Sync controls (only when connected) #} {% if isConnected %} -
+ + {# ── 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 %} +

+ {{ 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") }) %}
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
+ {% endcall %} - {% if lastSync %} -
-

- {{ __("youtube.likes.lastSync") }}: - + {# ── Recent likes ── #} + {% call section({ title: __("youtube.likes.recentLikes") }) %} + {% if recentLikes and recentLikes.length > 0 %} +

    + {% for like in recentLikes %} +
  • + {% if like["youtube-thumbnail"] %} + + + + {% endif %} +
    +

    + + {{ like.name }} + +

    +
    + {{ like["youtube-channel"] }} + +
    +
    +
  • + {% endfor %} +
+ + {% if totalLikePosts > 10 %} +

+ {{ 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") }} -

-
+ {% endif %} + {% else %} + {{ prose({ text: __("youtube.likes.noLikesYet") }) }} {% endif %} {% endcall %} -
- {% endif %} + {% endif %} {% endif %} + {% endblock %}