terry-staging #3
@@ -121,7 +121,7 @@ function LightboxView({
|
|||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
|
className="fixed inset-0 z-[100] flex flex-col bg-black/95 backdrop-blur-sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -179,42 +179,52 @@ function LightboxView({
|
|||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
<div className="absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||||
{index + 1} / {images.length}
|
{index + 1} / {images.length}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="relative inline-block max-h-[92vh] max-w-[92vw]"
|
className={`flex min-h-0 w-full flex-1 items-center justify-center px-4 pt-16 ${
|
||||||
onClick={(e) => e.stopPropagation()}
|
caption ? "pb-3" : "pb-16"
|
||||||
onTouchStart={(e) => {
|
}`}
|
||||||
touchStartX.current = e.touches[0].clientX;
|
|
||||||
}}
|
|
||||||
onTouchEnd={(e) => {
|
|
||||||
if (touchStartX.current == null) return;
|
|
||||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
|
||||||
if (Math.abs(dx) > 40) {
|
|
||||||
if (dx > 0) goPrev();
|
|
||||||
else goNext();
|
|
||||||
}
|
|
||||||
touchStartX.current = null;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
src={current.url}
|
className="max-h-full max-w-[92vw]"
|
||||||
alt={current.filename}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
|
onTouchStart={(e) => {
|
||||||
draggable={false}
|
touchStartX.current = e.touches[0].clientX;
|
||||||
/>
|
}}
|
||||||
{caption ? (
|
onTouchEnd={(e) => {
|
||||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-4 pt-12 text-sm leading-snug text-white sm:px-5 sm:pb-5">
|
if (touchStartX.current == null) return;
|
||||||
<div className="message-stream-copyable-text max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||||
{autolink(caption)}
|
if (Math.abs(dx) > 40) {
|
||||||
</div>
|
if (dx > 0) goPrev();
|
||||||
</div>
|
else goNext();
|
||||||
) : null}
|
}
|
||||||
|
touchStartX.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={current.url}
|
||||||
|
alt={current.filename}
|
||||||
|
className="max-h-full max-w-[92vw] object-contain select-none"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{caption ? (
|
||||||
|
<div
|
||||||
|
className="shrink-0 border-t border-white/10 bg-gradient-to-t from-black via-black/90 to-black/70 px-4 py-4 text-sm leading-snug text-white sm:px-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="message-stream-copyable-text mx-auto max-h-[32vh] max-w-[920px] overflow-y-auto whitespace-pre-wrap break-words [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
{autolink(caption)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { Globe, Menu, Search as SearchIcon, X } from "lucide-react";
|
import {
|
||||||
import { useState } from "react";
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Globe,
|
||||||
|
Menu,
|
||||||
|
Search as SearchIcon,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
import { useI18n, type Lang } from "../i18n";
|
import { useI18n, type Lang } from "../i18n";
|
||||||
@@ -55,6 +62,105 @@ function navClassName(active: boolean) {
|
|||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LanguageDropdownProps = {
|
||||||
|
lang: Lang;
|
||||||
|
setLang: (lang: Lang) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LanguageDropdown({
|
||||||
|
lang,
|
||||||
|
setLang,
|
||||||
|
ariaLabel,
|
||||||
|
className = "",
|
||||||
|
}: LanguageDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selected = LANG_OPTIONS.find((option) => option.code === lang);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
|
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
|
document.addEventListener("touchstart", closeOnOutside);
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside);
|
||||||
|
document.removeEventListener("touchstart", closeOnOutside);
|
||||||
|
window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="flex h-full w-full items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<Globe size={16} className="shrink-0 text-ark-gold/80" aria-hidden />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-left">
|
||||||
|
{selected?.label ?? lang}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
className={`shrink-0 text-neutral-400 transition ${
|
||||||
|
open ? "rotate-180 text-ark-gold" : ""
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{LANG_OPTIONS.map((option) => {
|
||||||
|
const active = option.code === lang;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.code}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={active}
|
||||||
|
onClick={() => {
|
||||||
|
setLang(option.code as Lang);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition ${
|
||||||
|
active
|
||||||
|
? "bg-ark-gold/10 text-ark-gold2"
|
||||||
|
: "text-neutral-200 hover:bg-white/10 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
{active ? (
|
||||||
|
<Check className="h-4 w-4" strokeWidth={2.4} />
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PublicLayout() {
|
export function PublicLayout() {
|
||||||
const { t, lang, setLang } = useI18n();
|
const { t, lang, setLang } = useI18n();
|
||||||
const { pathname, search, hash } = useLocation();
|
const { pathname, search, hash } = useLocation();
|
||||||
@@ -154,25 +260,12 @@ export function PublicLayout() {
|
|||||||
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
|
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
|
<LanguageDropdown
|
||||||
<Globe
|
lang={lang}
|
||||||
size={16}
|
setLang={setLang}
|
||||||
className="shrink-0 text-ark-gold/80"
|
ariaLabel={t("langLabel")}
|
||||||
aria-hidden
|
className="hidden h-10 w-36 md:block lg:w-40"
|
||||||
/>
|
/>
|
||||||
<select
|
|
||||||
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 lg:max-w-none"
|
|
||||||
value={lang}
|
|
||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
|
||||||
aria-label={t("langLabel")}
|
|
||||||
>
|
|
||||||
{LANG_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
|
||||||
@@ -197,21 +290,12 @@ export function PublicLayout() {
|
|||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
|
<LanguageDropdown
|
||||||
<Globe size={16} className="shrink-0 text-ark-gold/80" />
|
lang={lang}
|
||||||
<select
|
setLang={setLang}
|
||||||
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
|
ariaLabel={t("langLabel")}
|
||||||
value={lang}
|
className="mb-1"
|
||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
/>
|
||||||
aria-label={t("langLabel")}
|
|
||||||
>
|
|
||||||
{LANG_OPTIONS.map((option) => (
|
|
||||||
<option key={option.code} value={option.code}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className={navClassName(na("home"))}
|
className={navClassName(na("home"))}
|
||||||
|
|||||||
Reference in New Issue
Block a user