feat: theme toggle
This commit is contained in:
@@ -53,7 +53,12 @@ unreachable — the site must never 500 because Strapi is down.
|
|||||||
The site ships RTL Arabic; physical directions silently break it.
|
The site ships RTL Arabic; physical directions silently break it.
|
||||||
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
|
Check: `grep -rnE "\b(pl|pr|ml|mr|left|right)-[0-9]" apps/web/app` → nothing meaningful.
|
||||||
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
|
3. **Surfaces, not a theme.** `:root` *is* the dark surface; `data-surface="light"` flips a
|
||||||
subtree and repaints itself. Do not add a global theme toggle or a `dark:` variant.
|
subtree and repaints itself. Never add a second theme system or a `dark:` variant. **The
|
||||||
|
one exception, accepted deliberately:** the nav's theme toggle flips *which* surface `<html>`
|
||||||
|
stands on (dark default ↔ light), persisted via the `sharper-theme` cookie read in the root
|
||||||
|
loader and set by the root action — SSR-rendered so the first byte and first paint always
|
||||||
|
match. The mechanism stays surfaces-only: the toggle just re-roots them. `--surface-nav`
|
||||||
|
must keep a value in BOTH surface blocks.
|
||||||
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
|
4. **Figma is READ ONLY.** Only `get_design_context`, `get_screenshot`, `get_metadata`,
|
||||||
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
|
`get_variable_defs`. Never call anything that writes (`use_figma`, `generate_figma_design`,
|
||||||
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
|
`create_new_file`, `upload_assets`, shaders, plugins, …). Before the first
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { Brand } from "./Brand";
|
import { Brand } from "./Brand";
|
||||||
import { DesktopNav } from "./DesktopNav";
|
import { DesktopNav } from "./DesktopNav";
|
||||||
import { MobileNav } from "./MobileNav";
|
import { MobileNav } from "./MobileNav";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
import { getDefaultNavItems, getNavigationStrings } from "./nav-items";
|
||||||
import { cx } from "./styles";
|
import { cx } from "./styles";
|
||||||
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
import type { NavItem, NavigationStrings, NavSurface } from "./types";
|
||||||
@@ -53,9 +54,10 @@ export interface NavigationProps {
|
|||||||
* and the whole tree moves into a full-screen drawer.
|
* and the whole tree moves into a full-screen drawer.
|
||||||
*
|
*
|
||||||
* EU VISIBILITY: the updated design has no funding badge in the bar - the
|
* EU VISIBILITY: the updated design has no funding badge in the bar - the
|
||||||
* Actions slot holds the language globe alone. The mandatory "Funded by the
|
* Actions slot holds the language globe and the theme toggle. The mandatory
|
||||||
* European Union" lockup is carried by <Footer />, which renders on every page,
|
* "Funded by the European Union" lockup is carried by <Footer />, which
|
||||||
* and by the mobile drawer; it is not dropped from the site.
|
* renders on every page, and by the mobile drawer; it is not dropped from the
|
||||||
|
* site.
|
||||||
*/
|
*/
|
||||||
export function Navigation({
|
export function Navigation({
|
||||||
locale = "en",
|
locale = "en",
|
||||||
@@ -148,6 +150,11 @@ export function Navigation({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The theme control sits with the other actions at every breakpoint:
|
||||||
|
after the (desktop-only) language cluster on wide screens, beside
|
||||||
|
the hamburger on a compact bar. */}
|
||||||
|
<ThemeToggle locale={locale} />
|
||||||
|
|
||||||
<MobileNav
|
<MobileNav
|
||||||
items={navItems}
|
items={navItems}
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useFetcher, useRouteLoaderData } from "react-router";
|
||||||
|
|
||||||
|
import { createTranslator } from "~/i18n/t";
|
||||||
|
import type { AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
|
import type { Locale } from "../LanguageSwitcher";
|
||||||
|
import { cx, FOCUS_RING, HOVER_TRANSITION } from "./styles";
|
||||||
|
|
||||||
|
/** The document surface the root loader currently paints on <html>. */
|
||||||
|
interface RootLoaderData {
|
||||||
|
theme?: AppTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal sun, for "switch to light". Decorative - the label calls it. */
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal crescent moon, for "switch to dark". Decorative. */
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeToggleProps {
|
||||||
|
/** Reading locale, for the accessible label. */
|
||||||
|
locale: Locale;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's one global theme control: sun in dark mode, moon in light.
|
||||||
|
*
|
||||||
|
* It renders the target of the next click - the label and the icon describe
|
||||||
|
* what a press DOES ("Switch to light theme"), never the current state - and
|
||||||
|
* posts that target to the root action at `/`. The action persists the
|
||||||
|
* `sharper-theme` cookie, the loader re-runs and the shell repaints
|
||||||
|
* `data-surface` on <html>, so the whole tier-2 palette re-resolves. Because
|
||||||
|
* the current theme is read off the root loader (which the server fed from
|
||||||
|
* the cookie), the SSR markup and the first client render always agree.
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ locale, className }: ThemeToggleProps) {
|
||||||
|
const fetcher = useFetcher();
|
||||||
|
const rootData = useRouteLoaderData<RootLoaderData>("root");
|
||||||
|
const t = useMemo(() => createTranslator(locale), [locale]);
|
||||||
|
|
||||||
|
const theme: AppTheme = rootData?.theme ?? "dark";
|
||||||
|
const targetTheme: AppTheme = theme === "light" ? "dark" : "light";
|
||||||
|
const label =
|
||||||
|
targetTheme === "light" ? t("nav.themeToLight") : t("nav.themeToDark");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fetcher.Form action="/" method="post">
|
||||||
|
<input type="hidden" name="theme" value={targetTheme} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className={cx(
|
||||||
|
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center",
|
||||||
|
"rounded-control text-[var(--text-secondary)]",
|
||||||
|
"hover:text-[var(--text-primary)]",
|
||||||
|
HOVER_TRANSITION,
|
||||||
|
FOCUS_RING,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{targetTheme === "light" ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
</fetcher.Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
USAGE
|
||||||
|
---------------------------------------------------------------------------
|
||||||
|
// In the nav actions cluster, between the language control and the
|
||||||
|
// hamburger. Reads the theme from the root loader on its own.
|
||||||
|
<ThemeToggle locale={locale} />
|
||||||
|
------------------------------------------------------------------------ */
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
"closeMenu": "إغلاق القائمة",
|
"closeMenu": "إغلاق القائمة",
|
||||||
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
|
"homeLinkLabel": "الصفحة الرئيسية لمشروع SHARP-ER",
|
||||||
"languageMenu": "اللغة",
|
"languageMenu": "اللغة",
|
||||||
|
"themeToLight": "التبديل إلى المظهر الفاتح",
|
||||||
|
"themeToDark": "التبديل إلى المظهر الداكن",
|
||||||
"skipToContent": "تخطَّ إلى المحتوى",
|
"skipToContent": "تخطَّ إلى المحتوى",
|
||||||
"sections": {
|
"sections": {
|
||||||
"home": "الرئيسية",
|
"home": "الرئيسية",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"homeLinkLabel": "SHARP-ER home",
|
"homeLinkLabel": "SHARP-ER home",
|
||||||
"languageMenu": "Language",
|
"languageMenu": "Language",
|
||||||
|
"themeToLight": "Switch to light theme",
|
||||||
|
"themeToDark": "Switch to dark theme",
|
||||||
"skipToContent": "Skip to content",
|
"skipToContent": "Skip to content",
|
||||||
"sections": {
|
"sections": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
SITE THEME
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
The one global theme decision the site makes. The whole tier-2 palette is
|
||||||
|
still per-surface (see app/styles/semantic.css); this simply lets a reader
|
||||||
|
flip WHICH surface the document stands on, `:root` dark by default. The
|
||||||
|
preference travels as the `sharper-theme` cookie so the server renders the
|
||||||
|
same surface the next page will paint - no flash-of-wrong-theme, no
|
||||||
|
hydration mismatch.
|
||||||
|
========================================================================= */
|
||||||
|
|
||||||
|
export type AppTheme = "dark" | "light";
|
||||||
|
|
||||||
|
export const THEME_COOKIE = "sharper-theme";
|
||||||
|
|
||||||
|
/** Default when no cookie is present. `:root` is the dark surface. */
|
||||||
|
export const DEFAULT_THEME: AppTheme = "dark";
|
||||||
|
|
||||||
|
/** Parse the cookie header into a theme. Anything unknown resolves to dark. */
|
||||||
|
export function readTheme(cookieHeader: string | null): AppTheme {
|
||||||
|
if (!cookieHeader) return DEFAULT_THEME;
|
||||||
|
const pair = cookieHeader
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.find((part) => part.startsWith(`${THEME_COOKIE}=`));
|
||||||
|
const value = pair?.split("=")[1];
|
||||||
|
return value === "light" ? "light" : value === "dark" ? "dark" : DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Set-Cookie value that persists a theme choice for a year. */
|
||||||
|
export function themeCookie(theme: AppTheme): string {
|
||||||
|
return `${THEME_COOKIE}=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||||
|
}
|
||||||
+36
-5
@@ -4,7 +4,9 @@
|
|||||||
`Layout` renders the whole document, which is why <html lang> and <html dir>
|
`Layout` renders the whole document, which is why <html lang> and <html dir>
|
||||||
are correct in the FIRST SSR byte: the locale is read from the URL, not from
|
are correct in the FIRST SSR byte: the locale is read from the URL, not from
|
||||||
a cookie, a context provider or an effect, so the server and the first
|
a cookie, a context provider or an effect, so the server and the first
|
||||||
client render cannot disagree.
|
client render cannot disagree. The theme surface is the one deliberate
|
||||||
|
exception to "no cookies" - it is a cookie read in the loader, but because
|
||||||
|
the loader is what the server renders, the first byte still matches.
|
||||||
|
|
||||||
The header and the footer live here rather than in the ":lang" layout so the
|
The header and the footer live here rather than in the ":lang" layout so the
|
||||||
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
|
ErrorBoundary keeps its chrome: a 404 is a normal page of the site, with the
|
||||||
@@ -43,6 +45,7 @@ import {
|
|||||||
} from "~/lib/route-handle";
|
} from "~/lib/route-handle";
|
||||||
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
import { alternateUrls, buildMeta, canonicalUrl } from "~/lib/seo";
|
||||||
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
import { buildFooterLinkGroups, buildNavItems } from "~/lib/site-navigation";
|
||||||
|
import { DEFAULT_THEME, readTheme, themeCookie, type AppTheme } from "~/lib/theme";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Last-resort origin for the canonical and hreflang links. The root loader
|
* Last-resort origin for the canonical and hreflang links. The root loader
|
||||||
@@ -80,9 +83,31 @@ export function loader({ request }: Route.LoaderArgs) {
|
|||||||
dir: dirFor(locale),
|
dir: dirFor(locale),
|
||||||
pathname,
|
pathname,
|
||||||
siteUrl: getSiteUrl(request),
|
siteUrl: getSiteUrl(request),
|
||||||
|
/* Which surface the document stands on. Read from the theme cookie so the
|
||||||
|
server paints the same surface the reader asked for - the browser never
|
||||||
|
guesses, so there is no flash-of-wrong-theme and no hydration mismatch.
|
||||||
|
`:root` is dark, so the absence of a cookie is a dark first visit. */
|
||||||
|
theme: readTheme(request.headers.get("Cookie")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The root action is the theme toggle's destination (the nav posts here at
|
||||||
|
* `/`). It validates the requested theme and persists it with Set-Cookie; the
|
||||||
|
* loader re-runs and the shell repaints the `data-surface` on <html>.
|
||||||
|
*/
|
||||||
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const requested = formData.get("theme");
|
||||||
|
const theme: AppTheme =
|
||||||
|
requested === "light" ? "light" : requested === "dark" ? "dark" : DEFAULT_THEME;
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: { "Set-Cookie": themeCookie(theme) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function pathnameLocale(pathname: string): Locale {
|
function pathnameLocale(pathname: string): Locale {
|
||||||
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
const first = pathname.replace(/^\/+/, "").split("/")[0]?.toLowerCase();
|
||||||
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
|
return first === "ar" ? "ar" : first === "en" ? "en" : DEFAULT_LOCALE;
|
||||||
@@ -126,13 +151,19 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
const navItems = useMemo(() => buildNavItems(locale), [locale]);
|
const navItems = useMemo(() => buildNavItems(locale), [locale]);
|
||||||
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
|
const footerLinkGroups = useMemo(() => buildFooterLinkGroups(locale), [locale]);
|
||||||
|
|
||||||
const navSurface = useRouteHandleValue(readNavSurface) ?? "dark";
|
/* The theme cookie decides which surface the DOCUMENT stands on. The header
|
||||||
const footerSurface = useRouteHandleValue(readFooterSurface) ?? "dark";
|
and footer follow it, unless the route's handle forces one (`navSurface`
|
||||||
|
/ `footerSurface`) - so a page can still draw a light nav on a dark
|
||||||
|
surface in either theme. */
|
||||||
|
const rootData = useRouteLoaderData<typeof loader>("root");
|
||||||
|
const theme: AppTheme = rootData?.theme ?? DEFAULT_THEME;
|
||||||
|
|
||||||
|
const navSurface = useRouteHandleValue(readNavSurface) ?? theme;
|
||||||
|
const footerSurface = useRouteHandleValue(readFooterSurface) ?? theme;
|
||||||
|
|
||||||
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
|
/* Canonical and hreflang are emitted HERE, not from a route's `meta`: a leaf
|
||||||
`meta` replaces its parents' wholesale, so a page that writes its own
|
`meta` replaces its parents' wholesale, so a page that writes its own
|
||||||
would silently drop them. `handle.canonicalPath` is the escape hatch. */
|
would silently drop them. `handle.canonicalPath` is the escape hatch. */
|
||||||
const rootData = useRouteLoaderData<typeof loader>("root");
|
|
||||||
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
|
const siteUrl = rootData?.siteUrl ?? FALLBACK_SITE_URL;
|
||||||
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
|
const canonicalPath = useRouteHandleValue(readCanonicalPath) ?? pathname;
|
||||||
/* A detail route emits its own pair from the counterpart's real Strapi slug
|
/* A detail route emits its own pair from the counterpart's real Strapi slug
|
||||||
@@ -142,7 +173,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
|||||||
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
|
const ownsAlternates = useRouteHandleValue(readSelfAlternates) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang={locale} dir={dir} data-surface="dark">
|
<html lang={locale} dir={dir} data-surface={theme}>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ Because both surface blocks define the *same* token names, a component written
|
|||||||
against `--text-primary` is automatically correct on either one - which is why
|
against `--text-primary` is automatically correct on either one - which is why
|
||||||
components must never reach past a role to a primitive.
|
components must never reach past a role to a primitive.
|
||||||
|
|
||||||
|
**The one global exception, accepted deliberately - the theme toggle.** The
|
||||||
|
nav's sun/moon control (Navigation/ThemeToggle.tsx) lets a reader re-root the
|
||||||
|
surface the whole document stands on: the root action persists the
|
||||||
|
`sharper-theme` cookie, the root loader returns it and `<html
|
||||||
|
data-surface={theme}>` follows, so a light-theme visit stands on
|
||||||
|
`[data-surface="light"]` and every token re-resolves. The mechanism is still
|
||||||
|
surfaces-only - the toggle never repaints anything itself, it just chooses
|
||||||
|
which pole `:root` behaviour applies to - and the header/footer follow the
|
||||||
|
theme unless a route's `handle` forces a surface. `--surface-nav` (the
|
||||||
|
translucent bar) must stay defined in BOTH surface blocks, or a light-theme
|
||||||
|
nav resolves against the dark block.
|
||||||
|
|
||||||
Utility variants `surface-dark:` and `surface-light:` exist for the rare case a
|
Utility variants `surface-dark:` and `surface-light:` exist for the rare case a
|
||||||
utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
|
utility (not a token) has to differ, and `rtl:` / `ltr:` for direction. Prefer
|
||||||
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
|
CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `start-`, `end-`) so RTL
|
||||||
|
|||||||
@@ -17,6 +17,13 @@
|
|||||||
:root and [data-surface="dark"] share one block; [data-surface="light"]
|
:root and [data-surface="dark"] share one block; [data-surface="light"]
|
||||||
comes afterwards so it wins on the same element, and proximity makes the
|
comes afterwards so it wins on the same element, and proximity makes the
|
||||||
nearest data-surface ancestor win for any subtree.
|
nearest data-surface ancestor win for any subtree.
|
||||||
|
|
||||||
|
THEME TOGGLE (the declared exception to "no global theme"). The nav's
|
||||||
|
theme control flips WHICH surface the DOCUMENT stands on: the root action
|
||||||
|
persists `sharper-theme`, the root loader returns it, and <html
|
||||||
|
data-surface> follows - so light mode means the whole site stands on
|
||||||
|
[data-surface="light"] and every token re-resolves, including the nav and
|
||||||
|
footer, which follow the theme unless a route's handle forces a surface.
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -193,6 +200,9 @@
|
|||||||
--surface-danger-subtle: var(--color-red-100);
|
--surface-danger-subtle: var(--color-red-100);
|
||||||
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
|
--surface-inverse: var(--color-neutral-900); /* figma background/inverse #0b0b0a */
|
||||||
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */
|
--surface-footer: var(--color-white); /* figma 140:7812, the light footer */
|
||||||
|
/* The light bar floats the same way the dark one does - the ground colour at
|
||||||
|
90% with a blur behind it, so content shows through as it scrolls under. */
|
||||||
|
--surface-nav: color-mix(in srgb, var(--color-white) 90%, transparent);
|
||||||
--surface-selection: var(--color-green-200);
|
--surface-selection: var(--color-green-200);
|
||||||
|
|
||||||
/* --- text ------------------------------------------------------------- */
|
/* --- text ------------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user