Add retro LED footer marquee
This commit is contained in:
+132
@@ -495,6 +495,138 @@ html[lang="ar"] {
|
|||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
pointer-events: none;
|
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) {
|
@media (min-width: 640px) {
|
||||||
|
|||||||
@@ -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<string, string[]> = {
|
||||||
|
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 (
|
||||||
|
<div className="retro-led-message" aria-hidden={hidden}>
|
||||||
|
{litDots.map((dot) => (
|
||||||
|
<span
|
||||||
|
key={dot.key}
|
||||||
|
className="retro-led-dot retro-led-active-dot"
|
||||||
|
style={{
|
||||||
|
gridRow: dot.row,
|
||||||
|
gridColumn: dot.column,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetroLedMarquee({ text, className }: RetroLedMarqueeProps) {
|
||||||
|
const backgroundDots = Array.from({ length: MATRIX_COLUMNS * MATRIX_ROWS }, (_, index) => index);
|
||||||
|
const messageColumns = getMessageColumns(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={["retro-led-panel", className].filter(Boolean).join(" ")}
|
||||||
|
aria-label={text}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--retro-led-visible-columns": MATRIX_COLUMNS,
|
||||||
|
"--retro-led-visible-rows": MATRIX_ROWS,
|
||||||
|
"--retro-led-message-columns": messageColumns,
|
||||||
|
"--retro-led-repeat-gap-columns": REPEAT_GAP_COLUMNS,
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="retro-led-viewport">
|
||||||
|
<div className="retro-led-background" aria-hidden="true">
|
||||||
|
{backgroundDots.map((dot) => (
|
||||||
|
<span key={dot} className="retro-led-dot retro-led-background-dot" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="retro-led-track">
|
||||||
|
<RetroLedMessage text={text} />
|
||||||
|
<div className="retro-led-spacer" aria-hidden="true" />
|
||||||
|
<RetroLedMessage text={text} hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
|
import { RetroLedMarquee } from "@/components/layout/retro-led-marquee";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -20,6 +21,7 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const tNav = useTranslations("navigation");
|
const tNav = useTranslations("navigation");
|
||||||
const tFooter = useTranslations("footer");
|
const tFooter = useTranslations("footer");
|
||||||
|
const ledText = "MADE IN GERMANY BY MOH";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border bg-background">
|
<footer className="border-t border-border bg-background">
|
||||||
@@ -40,9 +42,12 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
|
|||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
<p className="text-xs text-muted-foreground/80">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
{tFooter("copyright", { year: new Date().getFullYear() })}
|
<p className="text-xs text-muted-foreground/80">
|
||||||
</p>
|
{tFooter("copyright", { year: new Date().getFullYear() })}
|
||||||
|
</p>
|
||||||
|
<RetroLedMarquee text={ledText} />
|
||||||
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user