mirror of
https://github.com/hex248/ob248.com.git
synced 2026-02-07 18:23:04 +00:00
full keyboard navigation
This commit is contained in:
104
src/App.tsx
104
src/App.tsx
@@ -6,7 +6,7 @@ import {
|
|||||||
Notes,
|
Notes,
|
||||||
} from "@nsmr/pixelart-react";
|
} from "@nsmr/pixelart-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, Route, Routes, useParams } from "react-router-dom";
|
import { Link, Route, Routes, useNavigate, useParams } from "react-router-dom";
|
||||||
import { AskAI } from "@/components/ask-ai";
|
import { AskAI } from "@/components/ask-ai";
|
||||||
import { ProjectListItem } from "@/components/ProjectListItem";
|
import { ProjectListItem } from "@/components/ProjectListItem";
|
||||||
import { TimeSince } from "@/components/time-since";
|
import { TimeSince } from "@/components/time-since";
|
||||||
@@ -39,7 +39,10 @@ export default App;
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1";
|
const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1";
|
||||||
|
const navigate = useNavigate();
|
||||||
const [asciiArt, setAsciiArt] = useState("");
|
const [asciiArt, setAsciiArt] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
const [hasPointerInteraction, setHasPointerInteraction] = useState(false);
|
||||||
const [asciiFile] = useState(
|
const [asciiFile] = useState(
|
||||||
() => asciiFiles[Math.floor(Math.random() * asciiFiles.length)],
|
() => asciiFiles[Math.floor(Math.random() * asciiFiles.length)],
|
||||||
);
|
);
|
||||||
@@ -48,6 +51,9 @@ function Home() {
|
|||||||
parseDate(b.metadata.date).getTime() -
|
parseDate(b.metadata.date).getTime() -
|
||||||
parseDate(a.metadata.date).getTime(),
|
parseDate(a.metadata.date).getTime(),
|
||||||
);
|
);
|
||||||
|
const visibleProjects = sortedProjects.filter(
|
||||||
|
(project) => isDevMode || !project.metadata.hidden,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
@@ -61,6 +67,78 @@ function Home() {
|
|||||||
};
|
};
|
||||||
}, [asciiFile]);
|
}, [asciiFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
if (visibleProjects.length === 0) return null;
|
||||||
|
if (prev === null) return null;
|
||||||
|
return Math.min(prev, visibleProjects.length - 1);
|
||||||
|
});
|
||||||
|
}, [visibleProjects.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibleProjects.length === 0) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented || event.isComposing) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
if (isInteractiveTarget(event.target)) return;
|
||||||
|
|
||||||
|
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
|
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
|
||||||
|
const columns = isDesktop ? 2 : 1;
|
||||||
|
|
||||||
|
let delta = 0;
|
||||||
|
if (key === "ArrowLeft" || key === "h") delta = -1;
|
||||||
|
if (key === "ArrowRight" || key === "l") delta = 1;
|
||||||
|
if (key === "ArrowUp" || key === "k") delta = -columns;
|
||||||
|
if (key === "ArrowDown" || key === "j") delta = columns;
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setActiveIndex((prev) => {
|
||||||
|
if (prev === null) return 0;
|
||||||
|
const next = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(visibleProjects.length - 1, prev + delta),
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "Enter") {
|
||||||
|
if (activeIndex === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const target = visibleProjects[activeIndex];
|
||||||
|
if (!target) return;
|
||||||
|
navigate(`/projects/${target.metadata.slug}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [activeIndex, navigate, visibleProjects]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const enablePointerInteraction = () => {
|
||||||
|
setHasPointerInteraction(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", enablePointerInteraction, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
window.addEventListener("pointerdown", enablePointerInteraction, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", enablePointerInteraction);
|
||||||
|
window.removeEventListener("pointerdown", enablePointerInteraction);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh flex flex-col items-center gap-2 text-2xl px-6 py-10">
|
<div className="min-h-dvh flex flex-col items-center gap-2 text-2xl px-6 py-10">
|
||||||
<div className="flex flex-col items-center gap-4 mb-4">
|
<div className="flex flex-col items-center gap-4 mb-4">
|
||||||
@@ -103,21 +181,41 @@ function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{sortedProjects.map((project) => (
|
{visibleProjects.map((project, index) => (
|
||||||
<ProjectListItem
|
<ProjectListItem
|
||||||
key={project.metadata.slug}
|
key={project.metadata.slug}
|
||||||
metadata={project.metadata}
|
metadata={project.metadata}
|
||||||
isDevMode={isDevMode}
|
isDevMode={isDevMode}
|
||||||
|
isActive={activeIndex !== null && index === activeIndex}
|
||||||
|
enableHover={hasPointerInteraction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-5xl flex items-center justify-between gap-4">
|
<div className="w-full max-w-5xl grid grid-cols-1 md:grid-cols-[1fr_auto_1fr] items-center gap-3 md:gap-4">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<AskAI name="me" inline />
|
<AskAI name="me" inline />
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-fg/80 text-center text-pretty">
|
||||||
|
arrows or hjkl, then enter
|
||||||
|
</p>
|
||||||
|
<div className="justify-self-center md:justify-self-end">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tagName = target.tagName;
|
||||||
|
return (
|
||||||
|
tagName === "INPUT" ||
|
||||||
|
tagName === "TEXTAREA" ||
|
||||||
|
tagName === "SELECT" ||
|
||||||
|
tagName === "BUTTON" ||
|
||||||
|
tagName === "A"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import type { ProjectMetadata } from "@/projects";
|
|||||||
export function ProjectListItem({
|
export function ProjectListItem({
|
||||||
metadata,
|
metadata,
|
||||||
isDevMode = false,
|
isDevMode = false,
|
||||||
|
isActive = false,
|
||||||
|
enableHover = true,
|
||||||
}: {
|
}: {
|
||||||
metadata: ProjectMetadata;
|
metadata: ProjectMetadata;
|
||||||
isDevMode?: boolean;
|
isDevMode?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
enableHover?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const tags = metadata.tags ? [...metadata.tags].sort() : [];
|
const tags = metadata.tags ? [...metadata.tags].sort() : [];
|
||||||
if (metadata.hidden && !isDevMode) return null;
|
if (metadata.hidden && !isDevMode) return null;
|
||||||
@@ -16,7 +20,9 @@ export function ProjectListItem({
|
|||||||
<Link
|
<Link
|
||||||
to={`/projects/${metadata.slug}`}
|
to={`/projects/${metadata.slug}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative block flex flex-col justify-between transition-colors duration-200 border-2 hover:border-accent",
|
"group relative block flex flex-col justify-between transition-colors duration-200 border-2",
|
||||||
|
enableHover && "hover:border-accent",
|
||||||
|
isActive && "border-accent",
|
||||||
isDevMode && metadata.hidden && "border-dashed border-accent",
|
isDevMode && metadata.hidden && "border-dashed border-accent",
|
||||||
)}
|
)}
|
||||||
data-tags={tags.join(",")}
|
data-tags={tags.join(",")}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Home } from "@nsmr/pixelart-react";
|
import { Home } from "@nsmr/pixelart-react";
|
||||||
import type { ReactNode } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { getProjectPrompt } from "@/lib/constants";
|
import { getProjectPrompt } from "@/lib/constants";
|
||||||
import type { ProjectMetadata } from "@/projects";
|
import type { ProjectMetadata } from "@/projects";
|
||||||
import { AskAI } from "./ask-ai";
|
import { AskAI } from "./ask-ai";
|
||||||
@@ -12,10 +12,36 @@ export function ProjectPage({
|
|||||||
metadata: ProjectMetadata;
|
metadata: ProjectMetadata;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const tags = metadata.tags ? [...metadata.tags].sort() : [];
|
const tags = metadata.tags ? [...metadata.tags].sort() : [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented || event.isComposing) return;
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
if (isInteractiveTarget(event.target)) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.key === "Escape" ||
|
||||||
|
event.key === "Backspace" ||
|
||||||
|
event.key === "q"
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-4xl px-6 py-4 text-md border my-8">
|
<div className="relative mx-auto w-full max-w-4xl px-6 py-4 text-md border my-8">
|
||||||
|
<p className="absolute top-4 right-6 text-xs text-fg/75">
|
||||||
|
esc or backspace to go back
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="inline-flex items-center text-sm hover:text-accent mb-4"
|
className="inline-flex items-center text-sm hover:text-accent mb-4"
|
||||||
@@ -37,6 +63,7 @@ export function ProjectPage({
|
|||||||
<div className="w-24 h-24 mb-2 border rounded" />
|
<div className="w-24 h-24 mb-2 border rounded" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="ml-auto flex flex-col items-end text-right">
|
||||||
<AskAI
|
<AskAI
|
||||||
name={metadata.title}
|
name={metadata.title}
|
||||||
prompt={getProjectPrompt(
|
prompt={getProjectPrompt(
|
||||||
@@ -46,6 +73,7 @@ export function ProjectPage({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{metadata.url ? (
|
{metadata.url ? (
|
||||||
<div className="flex flex-col mb-2">
|
<div className="flex flex-col mb-2">
|
||||||
<a
|
<a
|
||||||
@@ -112,3 +140,16 @@ export function ProjectPage({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
const tagName = target.tagName;
|
||||||
|
return (
|
||||||
|
tagName === "INPUT" ||
|
||||||
|
tagName === "TEXTAREA" ||
|
||||||
|
tagName === "SELECT" ||
|
||||||
|
tagName === "BUTTON" ||
|
||||||
|
tagName === "A"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user