From 212b78527b5009691966ddcc9b6626eef6d06bf1 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 8 Mar 2026 07:14:27 +0100 Subject: [PATCH] Add retro LED footer marquee --- app/globals.css | 132 ++++++++++++++++++++++++ components/layout/retro-led-marquee.tsx | 131 +++++++++++++++++++++++ components/layout/site-footer.tsx | 11 +- 3 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 components/layout/retro-led-marquee.tsx diff --git a/app/globals.css b/app/globals.css index e5a9dca..b600302 100644 --- a/app/globals.css +++ b/app/globals.css @@ -495,6 +495,138 @@ html[lang="ar"] { opacity: 0.92; pointer-events: none; } + + .retro-led-panel { + position: relative; + overflow: hidden; + --retro-led-dot-size: 0.12rem; + --retro-led-grid-gap: 0.16rem; + --retro-led-pad-x: 0.5rem; + --retro-led-pad-y: 0.36rem; + width: calc( + var(--retro-led-visible-columns) * var(--retro-led-dot-size) + + (var(--retro-led-visible-columns) - 1) * var(--retro-led-grid-gap) + 2 * var(--retro-led-pad-x) + ); + max-width: 100%; + border-radius: 0.75rem; + padding: var(--retro-led-pad-y) var(--retro-led-pad-x); + background: linear-gradient(180deg, hsl(228 38% 7%), hsl(232 42% 5%)); + } + + .retro-led-viewport { + position: relative; + overflow: hidden; + width: 100%; + min-height: calc( + var(--retro-led-visible-rows) * var(--retro-led-dot-size) + + (var(--retro-led-visible-rows) - 1) * var(--retro-led-grid-gap) + ); + } + + .retro-led-background { + position: absolute; + inset: var(--retro-led-pad-y) var(--retro-led-pad-x); + display: grid; + grid-template-columns: repeat(var(--retro-led-visible-columns), var(--retro-led-dot-size)); + grid-template-rows: repeat(var(--retro-led-visible-rows), var(--retro-led-dot-size)); + gap: var(--retro-led-grid-gap); + } + + .retro-led-track { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + display: flex; + width: max-content; + align-items: center; + animation: retro-led-marquee 16s linear infinite; + will-change: transform; + } + + .retro-led-message { + flex: none; + display: grid; + grid-template-columns: repeat(var(--retro-led-message-columns), var(--retro-led-dot-size)); + grid-template-rows: repeat(var(--retro-led-visible-rows), var(--retro-led-dot-size)); + column-gap: var(--retro-led-grid-gap); + row-gap: var(--retro-led-grid-gap); + width: calc( + var(--retro-led-message-columns) * var(--retro-led-dot-size) + + (var(--retro-led-message-columns) - 1) * var(--retro-led-grid-gap) + ); + height: calc( + var(--retro-led-visible-rows) * var(--retro-led-dot-size) + + (var(--retro-led-visible-rows) - 1) * var(--retro-led-grid-gap) + ); + } + + .retro-led-dot { + height: var(--retro-led-dot-size); + width: var(--retro-led-dot-size); + border-radius: 9999px; + } + + .retro-led-background-dot { + background-color: hsl(228 18% 22%); + } + + .retro-led-active-dot, + .retro-led-dot.is-lit { + background-color: hsl(var(--primary)); + } + + .retro-led-spacer { + flex: none; + width: calc( + var(--retro-led-repeat-gap-columns) * var(--retro-led-dot-size) + + (var(--retro-led-repeat-gap-columns) - 1) * var(--retro-led-grid-gap) + ); + height: calc( + var(--retro-led-visible-rows) * var(--retro-led-dot-size) + + (var(--retro-led-visible-rows) - 1) * var(--retro-led-grid-gap) + ); + } + + .dark .retro-led-panel { + background: linear-gradient(180deg, hsl(228 38% 7%), hsl(232 42% 5%)); + } + + @keyframes retro-led-marquee { + from { + transform: translate3d(0, 0, 0); + } + + to { + transform: translate3d( + calc( + -1 * ( + var(--retro-led-message-columns) * var(--retro-led-dot-size) + + (var(--retro-led-message-columns) - 1) * var(--retro-led-grid-gap) + + var(--retro-led-repeat-gap-columns) * var(--retro-led-dot-size) + + (var(--retro-led-repeat-gap-columns) - 1) * var(--retro-led-grid-gap) + ) + ), + 0, + 0 + ); + } + } + + @media (prefers-reduced-motion: reduce) { + .retro-led-track { + animation: none; + transform: translate3d(0, 0, 0); + } + } + + @media (max-width: 640px) { + .retro-led-panel { + --retro-led-dot-size: 0.11rem; + --retro-led-grid-gap: 0.12rem; + --retro-led-pad-x: 0.42rem; + --retro-led-pad-y: 0.3rem; + } + } } @media (min-width: 640px) { diff --git a/components/layout/retro-led-marquee.tsx b/components/layout/retro-led-marquee.tsx new file mode 100644 index 0000000..83f2975 --- /dev/null +++ b/components/layout/retro-led-marquee.tsx @@ -0,0 +1,131 @@ +import type { CSSProperties } from "react"; + +type RetroLedMarqueeProps = { + text: string; + className?: string; +}; + +const MATRIX_ROWS = 7; +const MATRIX_COLUMNS = 56; +const REPEAT_GAP_COLUMNS = 12; + +const GLYPHS: Record = { + A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"], + I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], + M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"], + N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"], + T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"], + Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"], + " ": ["000", "000", "000", "000", "000", "000", "000"], +}; + +function getGlyph(character: string) { + return GLYPHS[character] ?? GLYPHS[" "]; +} + +function getMessageColumns(text: string) { + return text + .toUpperCase() + .split("") + .reduce((total, character, index, characters) => { + const glyph = getGlyph(character); + const glyphWidth = glyph[0]?.length ?? 0; + const spacer = index === characters.length - 1 ? 0 : 1; + + return total + glyphWidth + spacer; + }, 0); +} + +function buildLitDots(text: string) { + const dots: Array<{ key: string; row: number; column: number }> = []; + let columnOffset = 0; + + text + .toUpperCase() + .split("") + .forEach((character, characterIndex, characters) => { + const glyph = getGlyph(character); + const glyphWidth = glyph[0]?.length ?? 0; + + glyph.forEach((row, rowIndex) => { + row.split("").forEach((cell, columnIndex) => { + if (cell !== "1") { + return; + } + + dots.push({ + key: `${characterIndex}-${rowIndex}-${columnIndex}`, + row: rowIndex + 1, + column: columnOffset + columnIndex + 1, + }); + }); + }); + + columnOffset += glyphWidth; + + if (characterIndex < characters.length - 1) { + columnOffset += 1; + } + }); + + return dots; +} + +function RetroLedMessage({ text, hidden = false }: { text: string; hidden?: boolean }) { + const litDots = buildLitDots(text); + + return ( +
+ {litDots.map((dot) => ( + + ))} +
+ ); +} + +export function RetroLedMarquee({ text, className }: RetroLedMarqueeProps) { + const backgroundDots = Array.from({ length: MATRIX_COLUMNS * MATRIX_ROWS }, (_, index) => index); + const messageColumns = getMessageColumns(text); + + return ( +
+
+ +
+ + +
+
+ ); +} diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx index 75cf826..744a6d7 100644 --- a/components/layout/site-footer.tsx +++ b/components/layout/site-footer.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useLocale, useTranslations } from "next-intl"; import { Container } from "@/components/layout/container"; +import { RetroLedMarquee } from "@/components/layout/retro-led-marquee"; import { getLocalizedPath } from "@/lib/locale"; const navItems = [ @@ -20,6 +21,7 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) { const locale = useLocale(); const tNav = useTranslations("navigation"); const tFooter = useTranslations("footer"); + const ledText = "MADE IN GERMANY BY MOH"; return ( );