Compare commits
2 Commits
bba1ab5cee
...
30cd5c7b13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30cd5c7b13 | ||
|
|
4b488913e4 |
34
README.md
34
README.md
@@ -92,10 +92,44 @@ The template also accepts a raw URL string or standard CasparCG XML
|
|||||||
`templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`,
|
`templateData` with component ids such as `url`, `scale`, `fit`, `autoplay`,
|
||||||
`muted`, `readyDelay`, and `maxWait`.
|
`muted`, `readyDelay`, and `maxWait`.
|
||||||
|
|
||||||
|
## Collage
|
||||||
|
|
||||||
|
Use `/collage` to render multiple social posts as independently scrolling
|
||||||
|
columns. The browser keeps only a small window of live posts per column, removes
|
||||||
|
cards after they scroll off the top, and fills new random cards at the bottom.
|
||||||
|
|
||||||
|
Repeated `url` parameters:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:3000/collage?url=https%3A%2F%2Fbsky.app%2F...&url=https%3A%2F%2Fx.com%2F...
|
||||||
|
```
|
||||||
|
|
||||||
|
Or a JSON array:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
http://localhost:3000/collage?urls=%5B%22https%3A%2F%2Fbsky.app%2F...%22%2C%22https%3A%2F%2Fx.com%2F...%22%5D
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful collage parameters:
|
||||||
|
|
||||||
|
- `spacing`: pixels between a post and anything else, including screen edges and other posts, default `48`.
|
||||||
|
- `fade`: optional pixels used to fade posts in/out at the top and bottom edges, default `0`.
|
||||||
|
- `columns`: number of post columns, default `3`.
|
||||||
|
- `repeatDistance`: minimum pixel distance before the same post can be reused near another live copy, default `900`.
|
||||||
|
- `hydrateDelay`: milliseconds between hydrating newly inserted provider embeds, default `180`.
|
||||||
|
- `duration`: seconds per scroll loop, default `360`.
|
||||||
|
- `repeat`: virtual scroll buffer multiplier, default `2`.
|
||||||
|
- `shuffle`: `1` to randomize post order on each page load, default `1`.
|
||||||
|
|
||||||
|
Collage card width is calculated from `width`, `spacing`, and `columns` so the
|
||||||
|
columns fill the configured screen width with only the chosen spacing at the
|
||||||
|
edges.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data.
|
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data.
|
||||||
- `GET /caspar` returns the CasparCG update-driven HTML template.
|
- `GET /caspar` returns the CasparCG update-driven HTML template.
|
||||||
|
- `GET /collage?url=...&url=...` returns a scrolling social post collage.
|
||||||
- `GET /providers` returns the loaded provider patterns.
|
- `GET /providers` returns the loaded provider patterns.
|
||||||
- `GET /healthz` returns a health check response.
|
- `GET /healthz` returns a health check response.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js",
|
"typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js && node --check public/caspar.js && node --check public/collage.js",
|
||||||
"build": "npm run typecheck",
|
"build": "npm run typecheck",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
|
|||||||
279
public/collage.js
Normal file
279
public/collage.js
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
const dataElement = document.getElementById("collage-data");
|
||||||
|
const stage = document.querySelector(".collage-stage");
|
||||||
|
const track = document.querySelector(".collage-track");
|
||||||
|
const config = JSON.parse(dataElement.textContent);
|
||||||
|
|
||||||
|
let lastFrame = performance.now();
|
||||||
|
let itemCursor = 0;
|
||||||
|
const columns = [];
|
||||||
|
const deviceScale = window.devicePixelRatio || 1;
|
||||||
|
const hydrationQueue = [];
|
||||||
|
const hydrateDelay = Math.max(Number(config.hydrateDelay) || 0, 0);
|
||||||
|
const placeholderHeight = 160;
|
||||||
|
const cardColumns = new WeakMap();
|
||||||
|
let trackTop = 0;
|
||||||
|
let hydrationTimer;
|
||||||
|
const cardObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const card = entry.target;
|
||||||
|
const column = cardColumns.get(card);
|
||||||
|
const previousExtent = cardExtent(card);
|
||||||
|
|
||||||
|
card.dataset.height = String(Math.max(entry.contentRect.height, placeholderHeight));
|
||||||
|
|
||||||
|
if (column) {
|
||||||
|
column.contentHeight += cardExtent(card) - previousExtent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function snapPixel(value) {
|
||||||
|
return Math.round(value * deviceScale) / deviceScale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value = "") {
|
||||||
|
return String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "") || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeScripts(root) {
|
||||||
|
for (const script of root.querySelectorAll("script")) {
|
||||||
|
const replacement = document.createElement("script");
|
||||||
|
|
||||||
|
for (const attribute of script.attributes) {
|
||||||
|
replacement.setAttribute(attribute.name, attribute.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
replacement.textContent = script.textContent;
|
||||||
|
script.replaceWith(replacement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHydration(card, item) {
|
||||||
|
if (!hydrateDelay) {
|
||||||
|
hydrateCard(card, item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrationQueue.push({ card, item });
|
||||||
|
|
||||||
|
if (!hydrationTimer) {
|
||||||
|
hydrationTimer = setInterval(hydrateNextCard, hydrateDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateNextCard() {
|
||||||
|
let next = hydrationQueue.shift();
|
||||||
|
|
||||||
|
while (next && !next.card.isConnected) {
|
||||||
|
next = hydrationQueue.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
clearInterval(hydrationTimer);
|
||||||
|
hydrationTimer = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hydrateCard(next.card, next.item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeProviderWidgets(root) {
|
||||||
|
window.twttr?.widgets?.load?.(root);
|
||||||
|
window.instgrm?.Embeds?.process?.();
|
||||||
|
window.bluesky?.scan?.(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateCard(card, item) {
|
||||||
|
if (item.type === "photo" && item.url) {
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.src = item.url;
|
||||||
|
image.alt = item.title || "";
|
||||||
|
card.replaceChildren(image);
|
||||||
|
} else {
|
||||||
|
card.innerHTML = item.html || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
executeScripts(card);
|
||||||
|
nudgeProviderWidgets(card);
|
||||||
|
card.classList.add("is-hydrated");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(column, item) {
|
||||||
|
const card = document.createElement("section");
|
||||||
|
card.className = `embed collage-card provider-${slugify(item.providerName)}`;
|
||||||
|
card.dataset.source = item.targetUrl;
|
||||||
|
card.dataset.height = String(placeholderHeight);
|
||||||
|
card.style.setProperty("--embed-width", `${config.cardWidth}px`);
|
||||||
|
cardColumns.set(card, column);
|
||||||
|
column.contentHeight += cardExtent(card);
|
||||||
|
cardObserver.observe(card);
|
||||||
|
scheduleHydration(card, item);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createColumn(index) {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.className = "collage-column";
|
||||||
|
element.dataset.column = String(index);
|
||||||
|
track.append(element);
|
||||||
|
|
||||||
|
return {
|
||||||
|
element,
|
||||||
|
index,
|
||||||
|
offset: 0,
|
||||||
|
contentHeight: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardExtent(card) {
|
||||||
|
return cardHeight(card) + config.spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHeight(card) {
|
||||||
|
return Math.max(Number(card.dataset.height) || 0, placeholderHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateY(column) {
|
||||||
|
return column.contentHeight - column.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTooCloseToSamePost(item, column) {
|
||||||
|
if (!config.repeatDistance || config.items.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDistanceSquared = config.repeatDistance * config.repeatDistance;
|
||||||
|
const nextY = candidateY(column);
|
||||||
|
|
||||||
|
for (const otherColumn of columns) {
|
||||||
|
const columnDistance = Math.abs(column.index - otherColumn.index) * (config.cardWidth + config.spacing);
|
||||||
|
|
||||||
|
for (const card of otherColumn.element.children) {
|
||||||
|
if (card.dataset.source !== item.targetUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingY = card.offsetTop - otherColumn.offset;
|
||||||
|
const yDistance = nextY - existingY;
|
||||||
|
const distanceSquared = (columnDistance * columnDistance) + (yDistance * yDistance);
|
||||||
|
|
||||||
|
if (distanceSquared < minDistanceSquared) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomCandidate() {
|
||||||
|
return config.items[Math.floor(Math.random() * config.items.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequentialCandidate(attempt) {
|
||||||
|
return config.items[(itemCursor + attempt) % config.items.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextItem(column) {
|
||||||
|
const maxAttempts = Math.max(config.items.length * 2, 8);
|
||||||
|
let fallback = config.items[0];
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||||
|
const item = config.shuffle ? randomCandidate() : sequentialCandidate(attempt);
|
||||||
|
fallback = item;
|
||||||
|
|
||||||
|
if (!isTooCloseToSamePost(item, column)) {
|
||||||
|
if (!config.shuffle) {
|
||||||
|
itemCursor += attempt + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.shuffle) {
|
||||||
|
itemCursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillColumn(column) {
|
||||||
|
const targetHeight = stage.clientHeight + Math.max(config.cardWidth, 720) + config.spacing;
|
||||||
|
let guard = 0;
|
||||||
|
|
||||||
|
while (column.contentHeight - column.offset < targetHeight && guard < 24) {
|
||||||
|
column.element.append(createCard(column, nextItem(column)));
|
||||||
|
guard += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillColumns() {
|
||||||
|
for (const column of columns) {
|
||||||
|
fillColumn(column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recycleColumn(column) {
|
||||||
|
let firstCard = column.element.firstElementChild;
|
||||||
|
|
||||||
|
while (firstCard && isFullyAboveStage(firstCard, column)) {
|
||||||
|
column.contentHeight -= cardExtent(firstCard);
|
||||||
|
column.offset -= cardExtent(firstCard);
|
||||||
|
cardObserver.unobserve(firstCard);
|
||||||
|
cardColumns.delete(firstCard);
|
||||||
|
firstCard.remove();
|
||||||
|
column.element.append(createCard(column, nextItem(column)));
|
||||||
|
firstCard = column.element.firstElementChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullyAboveStage(card, column) {
|
||||||
|
const cardBottom = trackTop + cardHeight(card) - column.offset;
|
||||||
|
|
||||||
|
return cardBottom <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollSpeed() {
|
||||||
|
const baseDistance = Math.max(stage.clientHeight, config.cardWidth * config.repeat);
|
||||||
|
return baseDistance / config.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frame(now) {
|
||||||
|
const elapsedSeconds = Math.min((now - lastFrame) / 1000, 0.1);
|
||||||
|
lastFrame = now;
|
||||||
|
|
||||||
|
for (const column of columns) {
|
||||||
|
column.offset += scrollSpeed() * elapsedSeconds;
|
||||||
|
recycleColumn(column);
|
||||||
|
fillColumn(column);
|
||||||
|
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < config.columns; index += 1) {
|
||||||
|
columns.push(createColumn(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
fillColumns();
|
||||||
|
trackTop = track.offsetTop;
|
||||||
|
|
||||||
|
for (const [index, column] of columns.entries()) {
|
||||||
|
const firstCard = column.element.firstElementChild;
|
||||||
|
const stagger = firstCard ? Math.min(index * config.spacing * 0.75, cardExtent(firstCard) * 0.4) : 0;
|
||||||
|
column.offset = stagger;
|
||||||
|
column.element.style.transform = `translate3d(0, ${snapPixel(-column.offset)}px, 0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
trackTop = track.offsetTop;
|
||||||
|
fillColumns();
|
||||||
|
});
|
||||||
|
observer.observe(stage);
|
||||||
|
|
||||||
|
requestAnimationFrame(frame);
|
||||||
@@ -77,6 +77,7 @@ label {
|
|||||||
|
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
|
textarea,
|
||||||
button {
|
button {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
border: 1px solid #c8d2dc;
|
border: 1px solid #c8d2dc;
|
||||||
@@ -85,12 +86,19 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 128px;
|
||||||
|
padding-block: 10px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
border-color: #153f66;
|
border-color: #153f66;
|
||||||
@@ -107,6 +115,15 @@ button {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collage-form {
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #d9e0e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-controls {
|
||||||
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.checkbox-label {
|
.checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -183,6 +200,99 @@ code {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.collage-page {
|
||||||
|
background: var(--chroma);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-page.transparent {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-stage {
|
||||||
|
width: min(100vw, var(--stage-width));
|
||||||
|
height: min(100vh, var(--stage-height));
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
padding: var(--collage-spacing);
|
||||||
|
contain: layout paint;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
#000 var(--collage-fade),
|
||||||
|
#000 calc(100% - var(--collage-fade)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
#000 var(--collage-fade),
|
||||||
|
#000 calc(100% - var(--collage-fade)),
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-track {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--collage-columns), minmax(0, var(--collage-card-width)));
|
||||||
|
gap: var(--collage-spacing);
|
||||||
|
width: max-content;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--collage-spacing);
|
||||||
|
width: var(--collage-card-width);
|
||||||
|
backface-visibility: hidden;
|
||||||
|
contain: layout style;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-card {
|
||||||
|
align-self: start;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: var(--collage-card-width);
|
||||||
|
max-height: none;
|
||||||
|
contain: layout style;
|
||||||
|
overflow: visible;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-card:not(.is-hydrated) {
|
||||||
|
min-height: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-card > iframe,
|
||||||
|
.collage-card > blockquote,
|
||||||
|
.collage-card > img,
|
||||||
|
.collage-card > video {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-card iframe,
|
||||||
|
.collage-card blockquote,
|
||||||
|
.collage-card img,
|
||||||
|
.collage-card video,
|
||||||
|
.collage-card twitter-widget,
|
||||||
|
.collage-card .twitter-tweet,
|
||||||
|
.collage-card .bluesky-embed {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collage-card > * {
|
||||||
|
margin-block: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
border-color: #d77;
|
border-color: #d77;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import { fetchOembed } from "./oembed.js";
|
import { fetchOembed } from "./oembed.js";
|
||||||
import { loadProviders } from "./providers.js";
|
import { loadProviders } from "./providers.js";
|
||||||
import { casparTemplatePage, errorPage, graphicPage, homePage } from "./templates.js";
|
import { casparTemplatePage, collagePage, errorPage, graphicPage, homePage } from "./templates.js";
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||||
const rootDir = join(__dirname, "..");
|
const rootDir = join(__dirname, "..");
|
||||||
@@ -51,10 +51,40 @@ function getNumber(searchParams, key, defaultValue, min, max) {
|
|||||||
return Math.min(Math.max(value, min), max);
|
return Math.min(Math.max(value, min), max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUrlList(searchParams) {
|
||||||
|
const repeatedUrls = searchParams.getAll("url").filter(Boolean);
|
||||||
|
|
||||||
|
if (repeatedUrls.length) {
|
||||||
|
return repeatedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlsValue = searchParams.get("urls") || "";
|
||||||
|
|
||||||
|
if (!urlsValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(urlsValue);
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.filter((url) => typeof url === "string" && url.trim());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to comma/newline parsing.
|
||||||
|
}
|
||||||
|
|
||||||
|
return urlsValue
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map((url) => url.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
async function serveStatic(requestUrl, response) {
|
async function serveStatic(requestUrl, response) {
|
||||||
const staticPaths = new Map([
|
const staticPaths = new Map([
|
||||||
["/styles.css", "styles.css"],
|
["/styles.css", "styles.css"],
|
||||||
["/caspar.js", "caspar.js"],
|
["/caspar.js", "caspar.js"],
|
||||||
|
["/collage.js", "collage.js"],
|
||||||
]);
|
]);
|
||||||
const path = staticPaths.get(requestUrl.pathname) || "";
|
const path = staticPaths.get(requestUrl.pathname) || "";
|
||||||
|
|
||||||
@@ -108,6 +138,68 @@ async function handleGraphic(requestUrl, response) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCollage(requestUrl, response) {
|
||||||
|
const urls = parseUrlList(requestUrl.searchParams).slice(0, 24);
|
||||||
|
|
||||||
|
if (!urls.length) {
|
||||||
|
send(response, 400, errorPage("Add repeated url parameters or a urls JSON array.", 400), "text/html; charset=utf-8");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
|
||||||
|
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
|
||||||
|
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 500, 220, 500);
|
||||||
|
const maxHeight = getNumber(requestUrl.searchParams, "maxheight", 480, 220, 2160);
|
||||||
|
const legacySpacing = requestUrl.searchParams.get("gap") || requestUrl.searchParams.get("padding");
|
||||||
|
const spacing = getNumber(requestUrl.searchParams, "spacing", Number(legacySpacing || 48), 0, 400);
|
||||||
|
const fade = getNumber(requestUrl.searchParams, "fade", 0, 0, 600);
|
||||||
|
const columns = getNumber(requestUrl.searchParams, "columns", 3, 1, 8);
|
||||||
|
const repeatDistance = getNumber(requestUrl.searchParams, "repeatDistance", 900, 0, 4000);
|
||||||
|
const hydrateDelay = getNumber(requestUrl.searchParams, "hydrateDelay", 180, 0, 2000);
|
||||||
|
const duration = getNumber(requestUrl.searchParams, "duration", 360, 10, 1200);
|
||||||
|
const repeat = getNumber(requestUrl.searchParams, "repeat", 2, 1, 8);
|
||||||
|
const shuffle = getBoolean(requestUrl.searchParams, "shuffle", true);
|
||||||
|
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
|
||||||
|
const autoplay = getBoolean(requestUrl.searchParams, "autoplay", true);
|
||||||
|
const muted = getBoolean(requestUrl.searchParams, "muted", true);
|
||||||
|
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
const { data } = await fetchOembed(url, { maxWidth, maxHeight });
|
||||||
|
return {
|
||||||
|
targetUrl: url,
|
||||||
|
embed: data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const items = results
|
||||||
|
.filter((result) => result.status === "fulfilled")
|
||||||
|
.map((result) => result.value);
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
throw new Error("No collage URLs could be resolved through oEmbed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
send(response, 200, collagePage({
|
||||||
|
items,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transparent,
|
||||||
|
chroma,
|
||||||
|
autoplay,
|
||||||
|
muted,
|
||||||
|
spacing,
|
||||||
|
fade,
|
||||||
|
columns,
|
||||||
|
repeatDistance,
|
||||||
|
hydrateDelay,
|
||||||
|
duration,
|
||||||
|
repeat,
|
||||||
|
shuffle,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRequest(request, response) {
|
async function handleRequest(request, response) {
|
||||||
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
||||||
|
|
||||||
@@ -126,6 +218,11 @@ async function handleRequest(request, response) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestUrl.pathname === "/collage") {
|
||||||
|
await handleCollage(requestUrl, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (requestUrl.pathname === "/providers") {
|
if (requestUrl.pathname === "/providers") {
|
||||||
const providers = await loadProviders();
|
const providers = await loadProviders();
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
|
|||||||
156
src/templates.js
156
src/templates.js
@@ -41,6 +41,28 @@ function cappedPixelValue(value, cap) {
|
|||||||
return `${Math.min(number, cap)}px`;
|
return `${Math.min(number, cap)}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collageCardWidth({ width, spacing, columns }) {
|
||||||
|
const availableWidth = width - (spacing * 2) - (spacing * Math.max(columns - 1, 0));
|
||||||
|
const columnWidth = Math.floor(availableWidth / columns);
|
||||||
|
|
||||||
|
return Math.max(columnWidth, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shuffleItems(items) {
|
||||||
|
const shuffled = [...items];
|
||||||
|
|
||||||
|
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * (index + 1));
|
||||||
|
[shuffled[index], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[index]];
|
||||||
|
}
|
||||||
|
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(value) {
|
||||||
|
return JSON.stringify(value).replaceAll("</", "<\\/");
|
||||||
|
}
|
||||||
|
|
||||||
function addIframePermissions(tag) {
|
function addIframePermissions(tag) {
|
||||||
const autoplayPermission = "autoplay";
|
const autoplayPermission = "autoplay";
|
||||||
|
|
||||||
@@ -323,9 +345,50 @@ export function homePage({ providersCount = 0 } = {}) {
|
|||||||
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<form class="collage-form" action="/collage" method="get">
|
||||||
|
<label for="urls">Collage URLs</label>
|
||||||
|
<textarea id="urls" name="urls" rows="6" placeholder="https://bsky.app/...\nhttps://x.com/...\nhttps://www.instagram.com/..."></textarea>
|
||||||
|
<div class="controls collage-controls">
|
||||||
|
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
|
||||||
|
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
|
||||||
|
<label>Spacing <input name="spacing" type="number" value="48" min="0" max="400"></label>
|
||||||
|
<label>Fade <input name="fade" type="number" value="0" min="0" max="600"></label>
|
||||||
|
<label>Columns <input name="columns" type="number" value="3" min="1" max="8"></label>
|
||||||
|
<label>Separation <input name="repeatDistance" type="number" value="900" min="0" max="4000"></label>
|
||||||
|
<label>Hydrate <input name="hydrateDelay" type="number" value="180" min="0" max="2000"></label>
|
||||||
|
<label>Duration <input name="duration" type="number" value="360" min="10" max="1200"></label>
|
||||||
|
<label>Repeat <input name="repeat" type="number" value="2" min="1" max="8"></label>
|
||||||
|
<label class="checkbox-label"><input name="shuffle" value="1" type="checkbox" checked> shuffle</label>
|
||||||
|
<label class="checkbox-label"><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Open collage</button>
|
||||||
|
</form>
|
||||||
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
document.querySelector(".collage-form").addEventListener("submit", (event) => {
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const textarea = form.querySelector("textarea[name='urls']");
|
||||||
|
const urls = textarea.value.split(/[\\n,]+/).map((url) => url.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
if (!urls.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
textarea.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new FormData(form);
|
||||||
|
data.delete("urls");
|
||||||
|
|
||||||
|
for (const url of urls) {
|
||||||
|
data.append("url", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.href = form.action + "?" + new URLSearchParams(data).toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -341,6 +404,84 @@ export function casparTemplatePage() {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function embedCardHtml({
|
||||||
|
targetUrl,
|
||||||
|
embed,
|
||||||
|
autoplay,
|
||||||
|
muted,
|
||||||
|
className = "embed",
|
||||||
|
widthCap = 500,
|
||||||
|
includeHeight = true,
|
||||||
|
}) {
|
||||||
|
const providerClass = `provider-${slugify(embed.provider_name)}`;
|
||||||
|
const embedWidth = cappedPixelValue(embed.width, widthCap);
|
||||||
|
const embedHeight = numericPixelValue(embed.height);
|
||||||
|
const embedStyle = [
|
||||||
|
embedWidth ? `--embed-width:${embedWidth}` : "",
|
||||||
|
includeHeight && embedHeight ? `--embed-height:${embedHeight}` : "",
|
||||||
|
].filter(Boolean).join(";");
|
||||||
|
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
|
||||||
|
const media = embed.type === "photo" && embed.url
|
||||||
|
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(embed.title || embed.provider_name || "")}">`
|
||||||
|
: html;
|
||||||
|
|
||||||
|
return `<section class="${escapeHtml(className)} ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
|
||||||
|
${media}
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collagePage({
|
||||||
|
items,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
transparent,
|
||||||
|
chroma,
|
||||||
|
autoplay = true,
|
||||||
|
muted = true,
|
||||||
|
spacing = 48,
|
||||||
|
fade = 0,
|
||||||
|
columns = 3,
|
||||||
|
repeatDistance = 900,
|
||||||
|
hydrateDelay = 180,
|
||||||
|
duration = 360,
|
||||||
|
repeat = 2,
|
||||||
|
shuffle = true,
|
||||||
|
}) {
|
||||||
|
const cardWidth = collageCardWidth({ width, spacing, columns });
|
||||||
|
const orderedItems = shuffle ? shuffleItems(items) : [...items];
|
||||||
|
const collageItems = orderedItems.map((item) => ({
|
||||||
|
targetUrl: item.targetUrl,
|
||||||
|
providerName: item.embed.provider_name || "",
|
||||||
|
type: item.embed.type || "rich",
|
||||||
|
title: item.embed.title || item.embed.provider_name || "",
|
||||||
|
url: item.embed.url || "",
|
||||||
|
html: prepareEmbedHtml(item.embed.html || "", { autoplay, muted }),
|
||||||
|
}));
|
||||||
|
const collageData = {
|
||||||
|
items: collageItems,
|
||||||
|
cardWidth,
|
||||||
|
columns,
|
||||||
|
repeatDistance,
|
||||||
|
hydrateDelay,
|
||||||
|
spacing,
|
||||||
|
duration,
|
||||||
|
repeat,
|
||||||
|
shuffle,
|
||||||
|
autoplay,
|
||||||
|
muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${commonHead({ title: "oEmbed Collage", htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
||||||
|
<body class="graphic collage-page ${transparent ? "transparent" : ""} is-ready" style="--stage-width:${width}px; --stage-height:${height}px; --chroma:${escapeHtml(chroma)}; --collage-spacing:${spacing}px; --collage-fade:${fade}px; --collage-columns:${columns}; --collage-card-width:${cardWidth}px; --collage-duration:${duration}s;">
|
||||||
|
<main class="collage-stage">
|
||||||
|
<div class="collage-track"></div>
|
||||||
|
</main>
|
||||||
|
<script id="collage-data" type="application/json">${safeJson(collageData)}</script>
|
||||||
|
<script src="/collage.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function graphicPage({
|
export function graphicPage({
|
||||||
targetUrl,
|
targetUrl,
|
||||||
embed,
|
embed,
|
||||||
@@ -357,24 +498,11 @@ export function graphicPage({
|
|||||||
maxWait = 10000,
|
maxWait = 10000,
|
||||||
}) {
|
}) {
|
||||||
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
||||||
const providerClass = `provider-${slugify(embed.provider_name)}`;
|
|
||||||
const embedWidth = cappedPixelValue(embed.width, 500);
|
|
||||||
const embedHeight = numericPixelValue(embed.height);
|
|
||||||
const embedStyle = [
|
|
||||||
embedWidth ? `--embed-width:${embedWidth}` : "",
|
|
||||||
embedHeight ? `--embed-height:${embedHeight}` : "",
|
|
||||||
].filter(Boolean).join(";");
|
|
||||||
const html = prepareEmbedHtml(embed.html || "", { autoplay, muted });
|
|
||||||
const media = embed.type === "photo" && embed.url
|
|
||||||
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
|
|
||||||
: html;
|
|
||||||
|
|
||||||
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
return `${commonHead({ title, htmlClass: transparent ? "graphic-document transparent" : "graphic-document" })}
|
||||||
<body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
<body class="graphic ${transparent ? "transparent" : ""} ${wait ? "is-loading" : "is-ready"}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
||||||
<main class="stage fit-${escapeHtml(fit)}">
|
<main class="stage fit-${escapeHtml(fit)}">
|
||||||
<section class="embed ${escapeHtml(providerClass)}" data-source="${escapeHtml(targetUrl)}"${embedStyle ? ` style="${escapeHtml(embedStyle)}"` : ""}>
|
${embedCardHtml({ targetUrl, embed, autoplay, muted })}
|
||||||
${media}
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
${wait ? revealScript({ readyDelay, maxWait }) : ""}
|
${wait ? revealScript({ readyDelay, maxWait }) : ""}
|
||||||
${autoplay ? autoplayAssistScript({ muted }) : ""}
|
${autoplay ? autoplayAssistScript({ muted }) : ""}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { casparTemplatePage, graphicPage, prepareEmbedHtml } from "../src/templates.js";
|
import { casparTemplatePage, collagePage, graphicPage, homePage, prepareEmbedHtml } from "../src/templates.js";
|
||||||
|
|
||||||
test("adds autoplay parameters and permissions to iframe embeds", () => {
|
test("adds autoplay parameters and permissions to iframe embeds", () => {
|
||||||
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');
|
const html = prepareEmbedHtml('<iframe src="https://player.example.com/video?id=1"></iframe>');
|
||||||
@@ -13,6 +13,22 @@ test("adds autoplay parameters and permissions to iframe embeds", () => {
|
|||||||
assert.match(html, /muted=1/);
|
assert.match(html, /muted=1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("home page includes single graphic and collage forms", () => {
|
||||||
|
const page = homePage({ providersCount: 12 });
|
||||||
|
|
||||||
|
assert.match(page, /<form action="\/graphic" method="get">/);
|
||||||
|
assert.match(page, /<form class="collage-form" action="\/collage" method="get">/);
|
||||||
|
assert.match(page, /<textarea id="urls" name="urls"/);
|
||||||
|
assert.match(page, /name="spacing" type="number" value="48"/);
|
||||||
|
assert.match(page, /name="fade" type="number" value="0"/);
|
||||||
|
assert.match(page, /name="columns" type="number" value="3"/);
|
||||||
|
assert.match(page, /name="repeatDistance" type="number" value="900"/);
|
||||||
|
assert.match(page, /name="hydrateDelay" type="number" value="180"/);
|
||||||
|
assert.match(page, /name="repeat" type="number" value="2"/);
|
||||||
|
assert.match(page, /name="shuffle" value="1" type="checkbox" checked/);
|
||||||
|
assert.match(page, /Open collage/);
|
||||||
|
});
|
||||||
|
|
||||||
test("adds autoplay attributes to video tags", () => {
|
test("adds autoplay attributes to video tags", () => {
|
||||||
const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>');
|
const html = prepareEmbedHtml('<video src="https://cdn.example.com/video.mp4"></video>');
|
||||||
|
|
||||||
@@ -143,7 +159,8 @@ test("adds a provider class for Bluesky embeds", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
assert.match(page, /<section class="embed provider-bluesky"/);
|
assert.match(page, /<section class="embed provider-bluesky"/);
|
||||||
assert.match(page, /--embed-width:500px;--embed-height:480px/);
|
assert.match(page, /--embed-width:500px/);
|
||||||
|
assert.doesNotMatch(page, /collage-card provider-example"[^>]*--embed-height/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("caps rendered embed width at 500px", () => {
|
test("caps rendered embed width at 500px", () => {
|
||||||
@@ -192,3 +209,86 @@ test("renders a CasparCG template shell", () => {
|
|||||||
assert.match(page, /<section class="embed provider-empty"/);
|
assert.match(page, /<section class="embed provider-empty"/);
|
||||||
assert.match(page, /<script src="\/caspar\.js"><\/script>/);
|
assert.match(page, /<script src="\/caspar\.js"><\/script>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders a vertical scrolling collage page with repeated embed cards", () => {
|
||||||
|
const page = collagePage({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
targetUrl: "https://example.com/post/1",
|
||||||
|
embed: {
|
||||||
|
provider_name: "Example",
|
||||||
|
type: "rich",
|
||||||
|
width: 700,
|
||||||
|
height: 480,
|
||||||
|
html: '<iframe src="https://player.example.com/1"></iframe>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetUrl: "https://example.com/post/2",
|
||||||
|
embed: {
|
||||||
|
provider_name: "Example",
|
||||||
|
type: "rich",
|
||||||
|
width: 500,
|
||||||
|
height: 480,
|
||||||
|
html: '<iframe src="https://player.example.com/2"></iframe>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
transparent: true,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
spacing: 48,
|
||||||
|
fade: 0,
|
||||||
|
columns: 3,
|
||||||
|
repeatDistance: 900,
|
||||||
|
duration: 360,
|
||||||
|
repeat: 3,
|
||||||
|
shuffle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /class="graphic collage-page transparent is-ready"/);
|
||||||
|
assert.match(page, /--collage-spacing:48px/);
|
||||||
|
assert.match(page, /--collage-fade:0px/);
|
||||||
|
assert.match(page, /--collage-columns:3/);
|
||||||
|
assert.match(page, /--collage-card-width:576px/);
|
||||||
|
assert.match(page, /--collage-duration:360s/);
|
||||||
|
assert.match(page, /<div class="collage-track"><\/div>/);
|
||||||
|
assert.match(page, /<script id="collage-data" type="application\/json">/);
|
||||||
|
assert.match(page, /"cardWidth":576/);
|
||||||
|
assert.match(page, /"columns":3/);
|
||||||
|
assert.match(page, /"repeatDistance":900/);
|
||||||
|
assert.match(page, /"hydrateDelay":180/);
|
||||||
|
assert.match(page, /"repeat":3/);
|
||||||
|
assert.match(page, /"shuffle":false/);
|
||||||
|
assert.match(page, /<script src="\/collage\.js"><\/script>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shrinks collage card width to fit the configured screen width", () => {
|
||||||
|
const page = collagePage({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
targetUrl: "https://example.com/post/1",
|
||||||
|
embed: {
|
||||||
|
provider_name: "Example",
|
||||||
|
type: "rich",
|
||||||
|
width: 500,
|
||||||
|
height: 480,
|
||||||
|
html: '<iframe src="https://player.example.com/1"></iframe>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
transparent: true,
|
||||||
|
chroma: "#00ff00",
|
||||||
|
spacing: 48,
|
||||||
|
columns: 4,
|
||||||
|
duration: 360,
|
||||||
|
repeat: 2,
|
||||||
|
shuffle: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(page, /--collage-card-width:260px/);
|
||||||
|
assert.match(page, /"cardWidth":260/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user