Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 16 additions & 17 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@
import { decodeEventLog, encodeEventTopics } from "viem";
import Link from "next/link";
import { ConnectWallet } from "../../components/ConnectWallet";
import { DropdownSelect } from "../../components/DropdownSelect";
import { GENRES, LANGUAGES } from "../../../lib/genres";

const genreOptions = [
{ value: "", label: "Select genre..." },
...GENRES.map((g) => ({ value: g, label: g })),
];
const languageOptions = LANGUAGES.map((l) => ({ value: l, label: l }));

const STORYLINE_CREATED_TOPIC = encodeEventTopics({
abi: [storylineCreatedEvent],
eventName: "StorylineCreated",
Expand All @@ -38,7 +45,7 @@
const [content, setContent] = useState("");
const hasDeadline = true; // mandatory 7-day deadline for all storylines

const { state, error, receipt, execute, reset } = usePublish();

Check warning on line 48 in src/app/create/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'reset' is assigned a value but never used
const { valid, charCount } = validateContentLength(content);
const titleValid = title.trim().length > 0;
const genreValid = genre.length > 0;
Expand Down Expand Up @@ -145,30 +152,22 @@
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-foreground mb-2 block text-sm">Genre</label>
<select
<DropdownSelect
value={genre}
onChange={(e) => setGenre(e.target.value)}
onChange={setGenre}
options={genreOptions}
placeholder="Select genre..."
disabled={busy}
className="border-border bg-surface text-foreground w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50"
>
<option value="">Select genre...</option>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
/>
</div>
<div>
<label className="text-foreground mb-2 block text-sm">Language</label>
<select
<DropdownSelect
value={language}
onChange={(e) => setLanguage(e.target.value)}
onChange={setLanguage}
options={languageOptions}
disabled={busy}
className="border-border bg-surface text-foreground w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none disabled:opacity-50"
>
{LANGUAGES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
/>
</div>
</div>

Expand Down
140 changes: 140 additions & 0 deletions src/components/DropdownSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"use client";

import { useState, useRef, useEffect, useCallback } from "react";

interface DropdownSelectProps {
value: string;
onChange: (value: string) => void;
options: readonly { value: string; label: string }[];
placeholder?: string;
disabled?: boolean;
className?: string;
size?: "sm" | "md";
}

export function DropdownSelect({
value,
onChange,
options,
placeholder,
disabled,
className,
size = "md",
}: DropdownSelectProps) {
const [open, setOpen] = useState(false);
const [focusIdx, setFocusIdx] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);

const selected = options.find((o) => o.value === value);
const label = selected?.label ?? placeholder ?? "Select...";

// Close on click outside
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);

// Scroll focused item into view
useEffect(() => {
if (!open || focusIdx < 0 || !listRef.current) return;
const items = listRef.current.children;
if (items[focusIdx]) {
(items[focusIdx] as HTMLElement).scrollIntoView({ block: "nearest" });
}
}, [focusIdx, open]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return;
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setFocusIdx(options.findIndex((o) => o.value === value));
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setFocusIdx((i) => Math.min(i + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setFocusIdx((i) => Math.max(i - 1, 0));
break;
case "Enter":
e.preventDefault();
if (focusIdx >= 0 && focusIdx < options.length) {
onChange(options[focusIdx].value);
setOpen(false);
}
break;
case "Escape":
e.preventDefault();
setOpen(false);
break;
}
},
[open, focusIdx, options, value, onChange, disabled],
);

const sizeClasses =
size === "sm"
? "px-2 py-1 text-xs"
: "px-3 py-2 text-sm";

return (
<div ref={containerRef} className={`relative ${className ?? ""}`}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setOpen((v) => !v)}
onKeyDown={handleKeyDown}
className={`border-border bg-surface flex w-full items-center justify-between rounded border ${sizeClasses} transition-colors focus:border-accent focus:outline-none disabled:opacity-50 ${
selected ? "text-foreground" : "text-muted"
}`}
>
<span className="truncate">{label}</span>
<span className="text-muted ml-2 text-[10px]">{open ? "\u25B2" : "\u25BC"}</span>
</button>

{open && (
<ul
ref={listRef}
role="listbox"
className="border-border bg-surface absolute z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border shadow-lg"
>
{options.map((opt, i) => (
<li
key={opt.value}
role="option"
aria-selected={opt.value === value}
onMouseEnter={() => setFocusIdx(i)}
onClick={() => {
onChange(opt.value);
setOpen(false);
}}
className={`cursor-pointer ${sizeClasses} transition-colors ${
opt.value === value
? "text-accent font-medium"
: focusIdx === i
? "bg-accent/10 text-foreground"
: "text-muted hover:text-foreground"
}`}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
49 changes: 27 additions & 22 deletions src/components/GenreLanguageFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,50 @@
"use client";

import { GENRES, LANGUAGES } from "../../lib/genres";
import { DropdownSelect } from "./DropdownSelect";

const genreOptions = [
{ value: "all", label: "All genres" },
...GENRES.map((g) => ({ value: g, label: g })),
];

const languageOptions = [
{ value: "all", label: "All languages" },
...LANGUAGES.map((l) => ({ value: l, label: l })),
];

export function GenreFilter({ active, tab, writer, lang }: { active: string; tab: string; writer: string; lang: string }) {
return (
<select
defaultValue={active}
onChange={(e) => {
<DropdownSelect
value={active}
onChange={(value) => {
const params = new URLSearchParams({ tab });
if (writer !== "all") params.set("writer", writer);
if (e.target.value !== "all") params.set("genre", e.target.value);
if (value !== "all") params.set("genre", value);
if (lang !== "all") params.set("lang", lang);
window.location.href = `/?${params.toString()}`;
}}
className="border-border bg-surface text-muted rounded border px-2 py-1 text-xs"
>
<option value="all">All genres</option>
{GENRES.map((g) => (
<option key={g} value={g}>{g}</option>
))}
</select>
options={genreOptions}
size="sm"
className="w-32"
/>
);
}

export function LanguageFilter({ active, tab, writer, genre }: { active: string; tab: string; writer: string; genre: string }) {
return (
<select
defaultValue={active}
onChange={(e) => {
<DropdownSelect
value={active}
onChange={(value) => {
const params = new URLSearchParams({ tab });
if (writer !== "all") params.set("writer", writer);
if (genre !== "all") params.set("genre", genre);
if (e.target.value !== "all") params.set("lang", e.target.value);
if (value !== "all") params.set("lang", value);
window.location.href = `/?${params.toString()}`;
}}
className="border-border bg-surface text-muted rounded border px-2 py-1 text-xs"
>
<option value="all">All languages</option>
{LANGUAGES.map((l) => (
<option key={l} value={l}>{l}</option>
))}
</select>
options={languageOptions}
size="sm"
className="w-36"
/>
);
}
Loading