mirror of
https://github.com/svemagie/indiekit-endpoint-youtube.git
synced 2026-04-02 15:54:59 +02:00
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:
@@ -197,6 +197,110 @@
|
|||||||
padding-block-start: var(--space-xl);
|
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 */
|
/* Public link banner */
|
||||||
.youtube-public-link {
|
.youtube-public-link {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { syncLikes, getLastSyncStatus } from "../likes-sync.js";
|
|||||||
|
|
||||||
export const likesController = {
|
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) {
|
async get(request, response, next) {
|
||||||
try {
|
try {
|
||||||
@@ -26,7 +26,7 @@ export const likesController = {
|
|||||||
if (!db) {
|
if (!db) {
|
||||||
return response.render("youtube-likes", {
|
return response.render("youtube-likes", {
|
||||||
title: response.locals.__("youtube.likes.title"),
|
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) {
|
if (!oauth?.clientId) {
|
||||||
return response.render("youtube-likes", {
|
return response.render("youtube-likes", {
|
||||||
title: response.locals.__("youtube.likes.title"),
|
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 tokens = await loadTokens(db);
|
||||||
const isConnected = Boolean(tokens?.refreshToken);
|
const isConnected = Boolean(tokens?.refreshToken);
|
||||||
const lastSync = await getLastSyncStatus(db);
|
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", {
|
response.render("youtube-likes", {
|
||||||
title: response.locals.__("youtube.likes.title"),
|
title: response.locals.__("youtube.likes.title"),
|
||||||
isConnected,
|
isConnected,
|
||||||
lastSync,
|
lastSync,
|
||||||
|
baseline,
|
||||||
|
seenCount,
|
||||||
|
recentLikes: recentLikes.map((l) => l.properties),
|
||||||
|
totalLikePosts,
|
||||||
mountPath: request.baseUrl,
|
mountPath: request.baseUrl,
|
||||||
|
error: qError || null,
|
||||||
|
success,
|
||||||
|
notice,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[YouTube] Likes page error:", error);
|
console.error("[YouTube] Likes page error:", error);
|
||||||
@@ -162,6 +203,14 @@ export const likesController = {
|
|||||||
return response.json(result);
|
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}`);
|
response.redirect(`${request.baseUrl}/likes?synced=${result.synced}&skipped=${result.skipped}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[YouTube] Manual sync error:", error);
|
console.error("[YouTube] Manual sync error:", error);
|
||||||
|
|||||||
@@ -24,16 +24,32 @@
|
|||||||
},
|
},
|
||||||
"likes": {
|
"likes": {
|
||||||
"title": "YouTube 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",
|
"connect": "YouTube-Konto verbinden",
|
||||||
"connected": "Verbunden",
|
"connected": "Verbunden",
|
||||||
|
"notConnected": "Nicht verbunden",
|
||||||
"disconnect": "Trennen",
|
"disconnect": "Trennen",
|
||||||
"sync": "Likes-Synchronisierung",
|
"sync": "Synchronisierung",
|
||||||
"syncNow": "Jetzt synchronisieren",
|
"syncNow": "Jetzt synchronisieren",
|
||||||
"lastSync": "Letzte Synchronisierung",
|
"lastSync": "Letzte Synchronisierung",
|
||||||
"newLikes": "neu",
|
"newLikes": "neu",
|
||||||
"skippedLikes": "bereits synchronisiert",
|
"skippedLikes": "bereits synchronisiert",
|
||||||
"totalLikes": "insgesamt auf YouTube",
|
"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": {
|
"error": {
|
||||||
"noOAuth": "YouTube OAuth ist nicht konfiguriert. Setze YOUTUBE_OAUTH_CLIENT_ID und YOUTUBE_OAUTH_CLIENT_SECRET."
|
"noOAuth": "YouTube OAuth ist nicht konfiguriert. Setze YOUTUBE_OAUTH_CLIENT_ID und YOUTUBE_OAUTH_CLIENT_SECRET."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,32 @@
|
|||||||
},
|
},
|
||||||
"likes": {
|
"likes": {
|
||||||
"title": "YouTube 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",
|
"connect": "Connect YouTube Account",
|
||||||
"connected": "Connected",
|
"connected": "Connected",
|
||||||
|
"notConnected": "Not connected",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"sync": "Likes Sync",
|
"sync": "Sync",
|
||||||
"syncNow": "Sync Now",
|
"syncNow": "Sync Now",
|
||||||
"lastSync": "Last sync",
|
"lastSync": "Last sync",
|
||||||
"newLikes": "new",
|
"newLikes": "new",
|
||||||
"skippedLikes": "already synced",
|
"skippedLikes": "already synced",
|
||||||
"totalLikes": "total on YouTube",
|
"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": {
|
"error": {
|
||||||
"noOAuth": "YouTube OAuth is not configured. Set YOUTUBE_OAUTH_CLIENT_ID and YOUTUBE_OAUTH_CLIENT_SECRET."
|
"noOAuth": "YouTube OAuth is not configured. Set YOUTUBE_OAUTH_CLIENT_ID and YOUTUBE_OAUTH_CLIENT_SECRET."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,115 @@
|
|||||||
{% extends "layouts/youtube.njk" %}
|
{% extends "layouts/youtube.njk" %}
|
||||||
|
|
||||||
{% block youtube %}
|
{% block youtube %}
|
||||||
<h2>{{ __("youtube.likes.title") }}</h2>
|
|
||||||
|
{# Flash messages via notificationBanner (error/success/notice come from controller) #}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
{{ prose({ text: error.message }) }}
|
{# Error state: no OAuth configured or DB unavailable #}
|
||||||
|
{{ notificationBanner({ type: "error", text: error }) }}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
{# OAuth connection status #}
|
{# ── Connection status ── #}
|
||||||
<div class="youtube-likes-status">
|
{% call section({ title: __("youtube.likes.status") }) %}
|
||||||
{% if isConnected %}
|
{% if isConnected %}
|
||||||
<div class="youtube-likes-connected">
|
<div class="youtube-likes-connection">
|
||||||
<span class="youtube-likes-status__badge youtube-likes-status__badge--connected">
|
<span class="youtube-likes-badge youtube-likes-badge--connected">
|
||||||
{{ __("youtube.likes.connected") }}
|
{{ __("youtube.likes.connected") }}
|
||||||
</span>
|
</span>
|
||||||
<form method="post" action="{{ mountPath }}/likes/disconnect" style="display:inline;">
|
<form method="post" action="{{ mountPath }}/likes/disconnect" class="youtube-likes-connection__action">
|
||||||
{{ button({ type: "submit", text: __("youtube.likes.disconnect"), classes: "button--secondary" }) }}
|
{{ button({ type: "submit", text: __("youtube.likes.disconnect") }) }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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>
|
<p>{{ __("youtube.likes.description") }}</p>
|
||||||
{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }}
|
{{ button({ href: mountPath + "/likes/connect", text: __("youtube.likes.connect") }) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
{% endcall %}
|
||||||
|
|
||||||
{# Sync controls (only when connected) #}
|
|
||||||
{% if isConnected %}
|
{% if isConnected %}
|
||||||
<div class="youtube-likes-sync" style="margin-block-start: var(--space-l);">
|
|
||||||
|
{# ── 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 %}
|
||||||
|
<p class="youtube-likes-sync-result">
|
||||||
|
{{ lastSync.synced }} {{ __("youtube.likes.newLikes") }},
|
||||||
|
{{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }},
|
||||||
|
{{ lastSync.total }} {{ __("youtube.likes.totalLikes") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{# ── Sync controls ── #}
|
||||||
{% call section({ title: __("youtube.likes.sync") }) %}
|
{% call section({ title: __("youtube.likes.sync") }) %}
|
||||||
<form method="post" action="{{ mountPath }}/likes/sync">
|
<form method="post" action="{{ mountPath }}/likes/sync">
|
||||||
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
|
{{ button({ type: "submit", text: __("youtube.likes.syncNow") }) }}
|
||||||
</form>
|
</form>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
{% if lastSync %}
|
{# ── Recent likes ── #}
|
||||||
<div class="youtube-likes-sync__info" style="margin-block-start: var(--space-s);">
|
{% call section({ title: __("youtube.likes.recentLikes") }) %}
|
||||||
<p>
|
{% if recentLikes and recentLikes.length > 0 %}
|
||||||
{{ __("youtube.likes.lastSync") }}:
|
<ul class="youtube-likes-list">
|
||||||
<time datetime="{{ lastSync.lastSyncAt }}">{{ lastSync.lastSyncAt }}</time>
|
{% 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>
|
</p>
|
||||||
<p>
|
{% endif %}
|
||||||
{{ lastSync.synced }} {{ __("youtube.likes.newLikes") }},
|
{% else %}
|
||||||
{{ lastSync.skipped }} {{ __("youtube.likes.skippedLikes") }},
|
{{ prose({ text: __("youtube.likes.noLikesYet") }) }}
|
||||||
{{ lastSync.total }} {{ __("youtube.likes.totalLikes") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user