fix: resolve all biomaly lint errors and warnings across CMS
- Fix CSS parser config (tailwindDirectives enabled) - Fix noDangerouslySetInnerHtml via SanitizedHtml component - Fix useExhaustiveDependencies in catalog-manager-dialog - Fix noArrayIndexKey across 26 files (stable keys) - Fix SVG a11y (titles, roles, aria-labels) - Fix label/input associations (htmlFor/id pairs) - Fix static element interactions (role + keyboard support) - Fix noImgElement, noDescendingSpecificity (disabled - external Habbo URLs) - Fix noNonNullAssertion, useTemplate, unused vars/imports - Add SanitizedHtml shared component - Migrate biome.json to 2.5.4 schema
This commit is contained in:
1 parent
dd66a5ba2e
commit
5e8a13a84f
77 files changed
+388
-274
No files matched your search
@@ -73,7 +73,9 @@ export function AdminMobileWrapper({
|
||||
|
||||
{/* Backdrop */}
|
||||
{open && (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="fixed inset-0 z-40 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
|
||||
@@ -7,14 +7,12 @@ import { deleteArticle } from "@/actions/admin-articles";
|
||||
export function ArticleCard({
|
||||
id,
|
||||
title,
|
||||
slug,
|
||||
image,
|
||||
createdAt,
|
||||
author,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
createdAt: string | null;
|
||||
author?: string;
|
||||
|
||||
@@ -163,8 +163,7 @@ function CatalogManagerInner({
|
||||
}
|
||||
}
|
||||
void loadRootPages();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
}, [open, initialRoots, state.rootPages.length, state.activeTabId, dispatch, loadRootPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
|
||||
|
||||
@@ -442,7 +442,9 @@ export function SortableTree({
|
||||
dispatch({
|
||||
type: "SET_CHILDREN",
|
||||
parentId,
|
||||
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
|
||||
children: newOrder
|
||||
.map((id) => nodes.get(id))
|
||||
.filter((n): n is NonNullable<typeof n> => Boolean(n)),
|
||||
});
|
||||
|
||||
if (!canEdit) return;
|
||||
|
||||
@@ -85,7 +85,9 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
|
||||
const invalidCount = parsed.length - validCount;
|
||||
|
||||
async function handleImport() {
|
||||
const valid = parsed.filter((r) => r.ok);
|
||||
const valid = parsed.filter(
|
||||
(r): r is ParsedRow & { baseId: number } => r.ok && r.baseId != null,
|
||||
);
|
||||
if (valid.length === 0) {
|
||||
toast.error("No valid rows to import");
|
||||
return;
|
||||
@@ -95,7 +97,7 @@ export function BulkImportItems({ pageId, onImported }: BulkImportItemsProps) {
|
||||
const res = await bulkCreateCatalogItems({
|
||||
pageId,
|
||||
rows: valid.map((row) => ({
|
||||
baseId: row.baseId!,
|
||||
baseId: row.baseId,
|
||||
credits: row.credits,
|
||||
points: row.points,
|
||||
pointsType: row.pointsType,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function DashboardChart({
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[200px] items-end gap-1" aria-label={title}>
|
||||
<div className="flex h-[200px] items-end gap-1" aria-label={title} role="img">
|
||||
{data.map((point) => (
|
||||
<div
|
||||
key={point.label}
|
||||
|
||||
@@ -175,7 +175,9 @@ export function AdminMediaGrid() {
|
||||
)}
|
||||
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("uploadSubtitle")}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -199,7 +201,7 @@ export function AdminMediaGrid() {
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted m-0">
|
||||
{t("uploadSubtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-xs theme-text-muted dark:theme-text-muted text-center py-10">
|
||||
@@ -218,16 +220,26 @@ export function AdminMediaGrid() {
|
||||
{filtered.map((f) => {
|
||||
const isSelected = selected === f.name;
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: grid tile uses role=button for selection
|
||||
<div
|
||||
key={f.name}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={f.name}
|
||||
onClick={() => setSelected(f.name)}
|
||||
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? "var(--admin-accent)"
|
||||
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelected(f.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
className="group border rounded-[12px] overflow-hidden bg-[var(--admin-canvas)] cursor-pointer transition-all duration-150"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? "var(--admin-accent)"
|
||||
: "color-mix(in srgb, var(--admin-text-muted) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
|
||||
@@ -89,12 +89,25 @@ export function MediaPicker({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
// biome-ignore lint/a11y/useSemanticElements: backdrop overlay uses role=button for click-to-close
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close media picker"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="bg-[var(--admin-surface)] text-[var(--admin-text)] rounded-2xl w-[min(90vw,680px)] max-h-[80vh] flex flex-col shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--admin-text-muted)]/14">
|
||||
|
||||
@@ -52,10 +52,11 @@ export function HomeLoginForm() {
|
||||
className="relative flex flex-col gap-6 rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
|
||||
>
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label htmlFor="login-username" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -67,10 +68,11 @@ export function HomeLoginForm() {
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label htmlFor="login-password" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -83,10 +85,11 @@ export function HomeLoginForm() {
|
||||
</fieldset>
|
||||
{needs2fa ? (
|
||||
<fieldset className="flex w-full flex-col gap-1">
|
||||
<label className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
<label htmlFor="login-2fa" className="block font-semibold text-gray-700 dark:text-gray-200">
|
||||
2FA Code
|
||||
</label>
|
||||
<input
|
||||
id="login-2fa"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
|
||||
@@ -226,6 +226,7 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: custom checkbox styled as button is intentional design */}
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
@@ -245,7 +246,10 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Terms accepted"
|
||||
>
|
||||
<title>Terms accepted</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -260,7 +264,17 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
name="terms"
|
||||
value={termsAccepted ? "1" : ""}
|
||||
/>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: terms label mirrors the checkbox button */}
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={termsAccepted}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setTermsAccepted(!termsAccepted);
|
||||
}
|
||||
}}
|
||||
className="font-semibold cursor-pointer"
|
||||
style={{ color: "var(--color-text-readable, var(--color-text))" }}
|
||||
onClick={() => setTermsAccepted(!termsAccepted)}
|
||||
|
||||
@@ -87,14 +87,14 @@ export function CmsInfoPopup() {
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<div className="cms-info-credit-list" aria-label="Credits">
|
||||
<section className="cms-info-credit-list" aria-label="Credits">
|
||||
{credits.map(([name, role]) => (
|
||||
<div className="cms-info-credit" key={name}>
|
||||
<span>{name}</span>
|
||||
<small>{role}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
) : null}
|
||||
|
||||
@@ -169,7 +169,10 @@ export default async function UserView({
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label="Enter"
|
||||
>
|
||||
<title>Enter</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -97,7 +97,10 @@ export function NavbarColorPicker() {
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
role="img"
|
||||
aria-label="Navbar kleur"
|
||||
>
|
||||
<title>Navbar kleur</title>
|
||||
<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r="0.5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r="0.5" fill="currentColor" />
|
||||
|
||||
@@ -88,7 +88,9 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
{active ? (
|
||||
<motion.div
|
||||
key="lightbox"
|
||||
onClick={close}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={active.alt}
|
||||
@@ -130,9 +132,8 @@ export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* Stop propagation so clicking the image itself doesn't close. */}
|
||||
{/* Clicking the dialog backdrop (not the figure) closes the lightbox. */}
|
||||
<figure
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: "min(92vw, 1100px)",
|
||||
|
||||
@@ -311,11 +311,6 @@ export default function RadioPlayer() {
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-label={
|
||||
listeners != null
|
||||
? `${listeners.toLocaleString()} listeners`
|
||||
: "Listeners"
|
||||
}
|
||||
style={{
|
||||
flex: "none",
|
||||
display: "inline-flex",
|
||||
@@ -380,7 +375,9 @@ export default function RadioPlayer() {
|
||||
|
||||
{/* Hidden stream element. preload="none" so nothing loads until the user
|
||||
hits play (and browsers block autoplay with sound regardless). */}
|
||||
<audio ref={audioRef} preload="none" />
|
||||
<audio ref={audioRef} preload="none">
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
@@ -155,12 +155,12 @@ export function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
||||
<Skeleton style={{ height: "12px", width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
{Array.from({ length: lines }, (_, i) => i).map((n) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
key={`skeleton-line-${n}`}
|
||||
style={{
|
||||
height: "12px",
|
||||
width: `${85 - i * 10}%`,
|
||||
width: `${85 - n * 10}%`,
|
||||
marginBottom: "0.6rem",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -41,7 +41,6 @@ export function CurrencyIcon({
|
||||
alt?: string;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/performance/noImgElement: small pixel art asset, not Next Image optimized
|
||||
<img
|
||||
src={currencyIconSrc(kind)}
|
||||
alt={alt ?? kind}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
interface SanitizedHtmlProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function SanitizedHtml({ html, className, style }: SanitizedHtmlProps) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -172,16 +172,14 @@ export async function ThemeVars() {
|
||||
? `https://fonts.googleapis.com/css2?family=${font.google}&display=swap`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: server-built CSS from sanitized admin settings */}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{customCss?.trim() ? (
|
||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: staff-authored theme CSS
|
||||
<style dangerouslySetInnerHTML={{ __html: customCss }} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{googleHref ? <link rel="stylesheet" href={googleHref} /> : null}
|
||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
||||
{customCss?.trim() ? (
|
||||
// Staff-authored custom CSS (housekeeping → Theme). Trusted input.
|
||||
<style dangerouslySetInnerHTML={{ __html: customCss }} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { resolveAsChild } from "@/lib/as-child";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
Reference in new issue
Block a user