Skip to content

Commit 8053a37

Browse files
Redesign Words page with Masonry layout and modern UI (#73)
* feat: redesign Words page with masonry grid and modern UI - Refactor `Words` page to use CSS multi-column masonry layout - Extract `WordCard` component with new design (Chips, Dropdowns) - Update `Spoiler` component to use CSS blur instead of image assets - Clean up `Words` page header controls into a consolidated toolbar - Remove unused `Spoiler.scss` Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * feat: redesign Words page with masonry grid and modern UI - Refactor `Words` page to use CSS multi-column masonry layout - Extract `WordCard` component with new design (Chips, Dropdowns) - Update `Spoiler` component to use CSS blur instead of image assets - Fix `Spoiler` text selection by adding dedicated hide button - Consolidate `Words` page header controls into a consolidated toolbar - Remove unused `Spoiler.scss` Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * feat: redesign Words and Flashcard pages with modern UI - Refactor `Words` page to use masonry grid layout - Create `WordCard` component with cleaner UI (Chips, Dropdowns) - Update `Flashcard` page to match the new design language - Improve `Spoiler` component: CSS blur, fix text selection, add min-height - Consolidate header controls for better mobile experience - Remove legacy `Spoiler.scss` Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * feat: add loading state to Anki count increment button - Prevent race conditions by disabling the button while request is in progress - Show loading spinner on the button during API call Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * fix: enforce type safety for priority colors - Update `getPriorityColor` to return strict string literals - Remove `as any` casts in `WordCard` and `Flashcard` components Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * fix: disable interactions during priority update - Disable dropdown and reduce opacity of priority chip when update is loading - Prevent concurrent priority update requests Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * feat: improve spoiler height in flashcards - Allow passing className to Spoiler component - Set full height for spoiler in flashcard view - Add missing getPriorityText function (actually checked and it was present, verified in previous step) Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * fix: enable flashcard spoiler toggle and text selection - Fix "Hide" button z-index in Spoiler component - Enable text selection in Flashcard component - Ensure Spoiler takes full height in Flashcard view Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> * fix: enable flashcard spoiler toggle and text selection - Fix "Hide" button z-index in Spoiler component - Enable text selection in Flashcard component by restricting drag start - Ensure Spoiler takes full height in Flashcard view Co-authored-by: Asutorufa <16442314+Asutorufa@users.noreply.github.com> --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
1 parent fef6ab4 commit 8053a37

File tree

5 files changed

+530
-452
lines changed

5 files changed

+530
-452
lines changed

src/Spoiler.scss

Lines changed: 0 additions & 48 deletions
This file was deleted.

src/WordCard.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
changePriority,
3+
EditIcon,
4+
getPriorityColor,
5+
getPriorityText,
6+
incrementRemindCount,
7+
ListWordResponse,
8+
Markdown,
9+
MoreVertIcon,
10+
Spoiler,
11+
TrashIcon
12+
} from "@/components";
13+
import {
14+
Button,
15+
Card,
16+
CardBody,
17+
CardHeader,
18+
Chip,
19+
Divider,
20+
Dropdown,
21+
DropdownItem,
22+
DropdownMenu,
23+
DropdownTrigger,
24+
Tooltip
25+
} from "@heroui/react";
26+
import { useState } from "react";
27+
28+
interface WordCardProps {
29+
word: ListWordResponse;
30+
onEdit: () => void;
31+
onDelete: () => void;
32+
onWordUpdate: (updatedWord: ListWordResponse) => void;
33+
}
34+
35+
export default function WordCard({ word, onEdit, onDelete, onWordUpdate }: WordCardProps) {
36+
const [loading, setLoading] = useState(false);
37+
const [isIncrementing, setIsIncrementing] = useState(false);
38+
39+
const handlePriorityChange = async (key: string) => {
40+
const priority = parseInt(key);
41+
if (priority === word.priority) return;
42+
setLoading(true);
43+
await changePriority(word.word, priority, (error) => {
44+
setLoading(false);
45+
if (!error) {
46+
onWordUpdate({ ...word, priority });
47+
}
48+
});
49+
};
50+
51+
const handleIncrement = async () => {
52+
if (isIncrementing) return;
53+
setIsIncrementing(true);
54+
await incrementRemindCount(word.word, (error) => {
55+
setIsIncrementing(false);
56+
if (!error) {
57+
onWordUpdate({ ...word, anki_count: word.anki_count + 1 });
58+
}
59+
});
60+
}
61+
62+
return (
63+
<Card className="break-inside-avoid mb-4 shadow-sm hover:shadow-md transition-shadow">
64+
<CardHeader className="flex justify-between items-start pb-0">
65+
<div className="flex flex-col max-w-[70%]">
66+
<h3 className="text-lg font-bold break-words">{word.word}</h3>
67+
<span className="text-tiny text-default-400">
68+
{new Date(word.update_time * 1000).toLocaleDateString()}
69+
</span>
70+
</div>
71+
72+
<div className="flex items-center gap-1">
73+
<Dropdown isDisabled={loading}>
74+
<DropdownTrigger>
75+
<Chip
76+
size="sm"
77+
variant="flat"
78+
color={getPriorityColor(word.priority)}
79+
className={`cursor-pointer px-2 min-w-unit-12 ${loading ? 'opacity-50 pointer-events-none' : ''}`}
80+
>
81+
{getPriorityText(word.priority)}
82+
</Chip>
83+
</DropdownTrigger>
84+
<DropdownMenu
85+
aria-label="Priority Actions"
86+
onAction={(key) => handlePriorityChange(key as string)}
87+
>
88+
<DropdownItem key="0" className="text-success">Low</DropdownItem>
89+
<DropdownItem key="1" className="text-warning">Medium</DropdownItem>
90+
<DropdownItem key="2" className="text-secondary">High</DropdownItem>
91+
</DropdownMenu>
92+
</Dropdown>
93+
94+
<Dropdown>
95+
<DropdownTrigger>
96+
<Button isIconOnly size="sm" variant="light" className="min-w-unit-8 w-unit-8 h-unit-8">
97+
<MoreVertIcon />
98+
</Button>
99+
</DropdownTrigger>
100+
<DropdownMenu aria-label="Card Actions">
101+
<DropdownItem
102+
key="edit"
103+
startContent={<EditIcon />}
104+
onPress={onEdit}
105+
>
106+
Edit
107+
</DropdownItem>
108+
<DropdownItem
109+
key="delete"
110+
className="text-danger"
111+
color="danger"
112+
startContent={<TrashIcon />}
113+
onPress={onDelete}
114+
>
115+
Delete
116+
</DropdownItem>
117+
</DropdownMenu>
118+
</Dropdown>
119+
</div>
120+
</CardHeader>
121+
122+
<CardBody className="pt-2">
123+
{word.example && (
124+
<div className="bg-default-50 rounded-lg p-3 mb-2 text-small">
125+
<Markdown>{word.example}</Markdown>
126+
</div>
127+
)}
128+
129+
<Spoiler>
130+
<Markdown>{word.explain}</Markdown>
131+
</Spoiler>
132+
133+
<Divider className="my-3"/>
134+
135+
<div className="flex justify-between items-center">
136+
<div className="text-tiny text-default-400">
137+
Review: {new Date(word.reminder_time * 1000).toLocaleDateString()}
138+
</div>
139+
<Tooltip content="Increment Anki Count">
140+
<Button
141+
size="sm"
142+
variant="light"
143+
radius="full"
144+
className="text-tiny px-1 h-6 min-w-12 text-default-400 hover:text-primary"
145+
onPress={handleIncrement}
146+
startContent={!isIncrementing ? <span className="text-small">+</span> : null}
147+
isLoading={isIncrementing}
148+
>
149+
{word.anki_count}
150+
</Button>
151+
</Tooltip>
152+
</div>
153+
</CardBody>
154+
</Card>
155+
);
156+
}

src/components.tsx

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { authorizedRequest } from "@/lib/api";
22
import { addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Switch, Textarea, useDisclosure, useDraggable } from "@heroui/react";
33
import { FC, ReactNode, useEffect, useRef, useState } from "react";
4-
import './Spoiler.scss';
54

65

76
export const SaveWordModal: FC<{
@@ -293,18 +292,50 @@ export function BookIcon() {
293292
)
294293
}
295294

296-
export const Spoiler: FC<{ children: ReactNode }> = ({ children }) => {
295+
export const Spoiler: FC<{ children: ReactNode, className?: string }> = ({ children, className }) => {
297296
const [hide, setHide] = useState(true);
298297
return (
299-
<div className={hide ? "Spoiler Spoiler--concealed Spoiler--animated" : ""} onClick={() => {
300-
setHide(prev => !prev);
301-
}}>
302-
<div className="Spoiler__content">{children}</div>
298+
<div
299+
className={`transition-all duration-500 overflow-hidden min-h-[3rem] ${hide ? "cursor-pointer relative" : ""} ${className || ""}`}
300+
onClick={() => {
301+
if (hide) setHide(false);
302+
}}
303+
>
304+
<div className={`transition-all duration-500 ${hide ? "blur-sm opacity-50 select-none grayscale" : "blur-0 opacity-100"} ${className?.includes('h-full') ? 'h-full' : ''}`}>
305+
{children}
306+
</div>
307+
{hide && (
308+
<div className="absolute inset-0 flex items-center justify-center z-10">
309+
<span className="text-tiny font-bold uppercase tracking-widest text-default-500 bg-default-100/50 px-2 py-1 rounded-full border border-default-200 shadow-sm backdrop-blur-md">
310+
Spoiler
311+
</span>
312+
</div>
313+
)}
314+
315+
{!hide && (
316+
<div className="flex justify-end mt-1 relative z-20">
317+
<button
318+
onClick={(e) => {
319+
e.stopPropagation();
320+
setHide(true);
321+
}}
322+
className="text-[10px] text-default-400 hover:text-default-600 uppercase tracking-wider font-bold cursor-pointer p-2"
323+
>
324+
Hide
325+
</button>
326+
</div>
327+
)}
303328
</div>
304329
);
305330
}
306331

307-
export function getPriorityColor(priority: number) {
332+
export function MoreVertIcon() {
333+
return (
334+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2" /></svg>
335+
)
336+
}
337+
338+
export function getPriorityColor(priority: number): "success" | "warning" | "secondary" {
308339
if (priority === 0) {
309340
return "success"
310341
} else if (priority === 1) {

0 commit comments

Comments
 (0)