diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:11:53 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 11:12:00 +0900 |
| commit | 7ca9941982a7d7a4c126d215770ce71ad2f7f427 (patch) | |
| tree | 0178b48094e9b7b143fd47c4d8479d3d588bb1d7 /src/client/components | |
| parent | 8f1a08fefee3a8e928baec741c830a88a4cd7200 (diff) | |
| download | kioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.tar.gz kioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.tar.zst kioku-7ca9941982a7d7a4c126d215770ce71ad2f7f427.zip | |
feat(client): read decks/cards/study from IndexedDB first
Switch deckAtom, cardsByDeckAtomFamily, noteTypesAtom, and studyDataAtom
to a stale-while-revalidate pattern: read from IndexedDB synchronously,
trigger sync in the background, and refetch on sync_complete. When local
is empty, await a single bootstrap pull before deciding there's no data.
Add study-builder to assemble StudyCards from LocalCard + Note + NoteType
+ field values, replacing the server /study endpoint dependency. The
study session can now run end-to-end offline.
Disable submit on all write modals when offline since writes still
require the server. Add a "Showing cached data" hint to the sync status
indicator. Drop cacheStudyCards (cards arrive via regular sync pull now)
and update page tests to reflect that lists no longer refresh by hitting
the GET API.
Diffstat (limited to 'src/client/components')
| -rw-r--r-- | src/client/components/CreateCardModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/CreateDeckModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/CreateNoteModal.tsx | 8 | ||||
| -rw-r--r-- | src/client/components/CreateNoteTypeModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/DeleteCardModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/DeleteDeckModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/DeleteNoteTypeModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/EditCardModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/EditDeckModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/EditNoteModal.tsx | 8 | ||||
| -rw-r--r-- | src/client/components/EditNoteTypeModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/ImportNotesModal.tsx | 6 | ||||
| -rw-r--r-- | src/client/components/SyncStatusIndicator.tsx | 6 |
14 files changed, 74 insertions, 14 deletions
diff --git a/src/client/components/CreateCardModal.tsx b/src/client/components/CreateCardModal.tsx index 3913e82..8dbaa79 100644 --- a/src/client/components/CreateCardModal.tsx +++ b/src/client/components/CreateCardModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface CreateCardModalProps { isOpen: boolean; @@ -18,6 +20,7 @@ export function CreateCardModal({ const [back, setBack] = useState(""); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const resetForm = () => { setFront(""); @@ -163,7 +166,8 @@ export function CreateCardModal({ </button> <button type="submit" - disabled={isSubmitting || !isFormValid} + disabled={isSubmitting || !isFormValid || !isOnline} + title={!isOnline ? "Reconnect to create a card" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create Card"} diff --git a/src/client/components/CreateDeckModal.tsx b/src/client/components/CreateDeckModal.tsx index 4541a68..34d46e7 100644 --- a/src/client/components/CreateDeckModal.tsx +++ b/src/client/components/CreateDeckModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface CreateDeckModalProps { isOpen: boolean; @@ -16,6 +18,7 @@ export function CreateDeckModal({ const [description, setDescription] = useState(""); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const resetForm = () => { setName(""); @@ -160,7 +163,8 @@ export function CreateDeckModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim()} + disabled={isSubmitting || !name.trim() || !isOnline} + title={!isOnline ? "Reconnect to create a deck" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create Deck"} diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx index cc39bf6..f3809ea 100644 --- a/src/client/components/CreateNoteModal.tsx +++ b/src/client/components/CreateNoteModal.tsx @@ -1,7 +1,9 @@ import { faSpinner } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useAtomValue } from "jotai"; import { type FormEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface NoteField { id: string; @@ -49,6 +51,7 @@ export function CreateNoteModal({ const [isLoadingNoteType, setIsLoadingNoteType] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [hasLoadedNoteTypes, setHasLoadedNoteTypes] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const fetchNoteTypeDetails = useCallback(async (noteTypeId: string) => { setIsLoadingNoteType(true); @@ -346,7 +349,10 @@ export function CreateNoteModal({ </button> <button type="submit" - disabled={isSubmitting || !isFormValid || isLoading} + disabled={ + isSubmitting || !isFormValid || isLoading || !isOnline + } + title={!isOnline ? "Reconnect to create a note" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create Note"} diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx index 4c3b232..bbd43a1 100644 --- a/src/client/components/CreateNoteTypeModal.tsx +++ b/src/client/components/CreateNoteTypeModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface CreateNoteTypeModalProps { isOpen: boolean; @@ -18,6 +20,7 @@ export function CreateNoteTypeModal({ const [isReversible, setIsReversible] = useState(false); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const resetForm = () => { setName(""); @@ -197,7 +200,8 @@ export function CreateNoteTypeModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim()} + disabled={isSubmitting || !name.trim() || !isOnline} + title={!isOnline ? "Reconnect to create" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Creating..." : "Create"} diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx index d9cf098..99514be 100644 --- a/src/client/components/DeleteCardModal.tsx +++ b/src/client/components/DeleteCardModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface Card { id: string; @@ -23,6 +25,7 @@ export function DeleteCardModal({ }: DeleteCardModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const handleClose = () => { setError(null); @@ -138,7 +141,8 @@ export function DeleteCardModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting} + disabled={isDeleting || !isOnline} + title={!isOnline ? "Reconnect to delete" : undefined} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx index edc6093..954431e 100644 --- a/src/client/components/DeleteDeckModal.tsx +++ b/src/client/components/DeleteDeckModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface Deck { id: string; @@ -21,6 +23,7 @@ export function DeleteDeckModal({ }: DeleteDeckModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const handleClose = () => { setError(null); @@ -129,7 +132,8 @@ export function DeleteDeckModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting} + disabled={isDeleting || !isOnline} + title={!isOnline ? "Reconnect to delete" : undefined} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx index 5d81fdc..3ed22ec 100644 --- a/src/client/components/DeleteNoteModal.tsx +++ b/src/client/components/DeleteNoteModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface DeleteNoteModalProps { isOpen: boolean; @@ -18,6 +20,7 @@ export function DeleteNoteModal({ }: DeleteNoteModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const handleClose = () => { setError(null); @@ -127,7 +130,8 @@ export function DeleteNoteModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting} + disabled={isDeleting || !isOnline} + title={!isOnline ? "Reconnect to delete" : undefined} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx index db93482..2fbf808 100644 --- a/src/client/components/DeleteNoteTypeModal.tsx +++ b/src/client/components/DeleteNoteTypeModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface NoteType { id: string; @@ -21,6 +23,7 @@ export function DeleteNoteTypeModal({ }: DeleteNoteTypeModalProps) { const [error, setError] = useState<string | null>(null); const [isDeleting, setIsDeleting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const handleClose = () => { setError(null); @@ -129,7 +132,8 @@ export function DeleteNoteTypeModal({ <button type="button" onClick={handleDelete} - disabled={isDeleting} + disabled={isDeleting || !isOnline} + title={!isOnline ? "Reconnect to delete" : undefined} className="px-4 py-2 bg-error hover:bg-error/90 text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx index 726a003..288bfd6 100644 --- a/src/client/components/EditCardModal.tsx +++ b/src/client/components/EditCardModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface Card { id: string; @@ -26,6 +28,7 @@ export function EditCardModal({ const [back, setBack] = useState(""); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); // Sync form state when card changes useEffect(() => { @@ -164,7 +167,8 @@ export function EditCardModal({ </button> <button type="submit" - disabled={isSubmitting || !isFormValid} + disabled={isSubmitting || !isFormValid || !isOnline} + title={!isOnline ? "Reconnect to save changes" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Saving..." : "Save Changes"} diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index 9a79de8..e9c2b7b 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface Deck { id: string; @@ -35,6 +37,7 @@ export function EditDeckModal({ const [isLoadingNoteTypes, setIsLoadingNoteTypes] = useState(false); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const fetchNoteTypes = useCallback(async () => { setIsLoadingNoteTypes(true); @@ -216,7 +219,8 @@ export function EditDeckModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim()} + disabled={isSubmitting || !name.trim() || !isOnline} + title={!isOnline ? "Reconnect to save changes" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Saving..." : "Save Changes"} diff --git a/src/client/components/EditNoteModal.tsx b/src/client/components/EditNoteModal.tsx index ac22332..cd2c58c 100644 --- a/src/client/components/EditNoteModal.tsx +++ b/src/client/components/EditNoteModal.tsx @@ -1,7 +1,9 @@ import { faSpinner } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useAtomValue } from "jotai"; import { type FormEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface NoteField { id: string; @@ -54,6 +56,7 @@ export function EditNoteModal({ const [isLoadingNote, setIsLoadingNote] = useState(false); const [isLoadingNoteType, setIsLoadingNoteType] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); const fetchNoteTypeDetails = useCallback(async (noteTypeId: string) => { setIsLoadingNoteType(true); @@ -297,7 +300,10 @@ export function EditNoteModal({ </button> <button type="submit" - disabled={isSubmitting || !isFormValid || isLoading} + disabled={ + isSubmitting || !isFormValid || isLoading || !isOnline + } + title={!isOnline ? "Reconnect to save changes" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Saving..." : "Save Changes"} diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx index 27ef5d8..5916ff0 100644 --- a/src/client/components/EditNoteTypeModal.tsx +++ b/src/client/components/EditNoteTypeModal.tsx @@ -1,5 +1,7 @@ +import { useAtomValue } from "jotai"; import { type FormEvent, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; interface NoteType { id: string; @@ -28,6 +30,7 @@ export function EditNoteTypeModal({ const [isReversible, setIsReversible] = useState(false); const [error, setError] = useState<string | null>(null); const [isSubmitting, setIsSubmitting] = useState(false); + const isOnline = useAtomValue(isOnlineAtom); // Sync form state when noteType changes useEffect(() => { @@ -208,7 +211,8 @@ export function EditNoteTypeModal({ </button> <button type="submit" - disabled={isSubmitting || !name.trim()} + disabled={isSubmitting || !name.trim() || !isOnline} + title={!isOnline ? "Reconnect to save changes" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > {isSubmitting ? "Saving..." : "Save Changes"} diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx index d3a2c0c..a38ac8f 100644 --- a/src/client/components/ImportNotesModal.tsx +++ b/src/client/components/ImportNotesModal.tsx @@ -5,8 +5,10 @@ import { faSpinner, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useAtomValue } from "jotai"; import { type ChangeEvent, useCallback, useEffect, useState } from "react"; import { ApiClientError, apiClient } from "../api"; +import { isOnlineAtom } from "../atoms"; import { parseCSV } from "../utils/csvParser"; interface NoteField { @@ -64,6 +66,7 @@ export function ImportNotesModal({ }: ImportNotesModalProps) { const [phase, setPhase] = useState<ImportPhase>("upload"); const [error, setError] = useState<string | null>(null); + const isOnline = useAtomValue(isOnlineAtom); const [noteTypes, setNoteTypes] = useState<NoteType[]>([]); const [validatedRows, setValidatedRows] = useState<ValidatedRow[]>([]); const [validationErrors, setValidationErrors] = useState<ValidationError[]>( @@ -490,7 +493,8 @@ export function ImportNotesModal({ <button type="button" onClick={handleImport} - disabled={validatedRows.length === 0} + disabled={validatedRows.length === 0 || !isOnline} + title={!isOnline ? "Reconnect to import notes" : undefined} className="px-4 py-2 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed" > Import {validatedRows.length} Note(s) diff --git a/src/client/components/SyncStatusIndicator.tsx b/src/client/components/SyncStatusIndicator.tsx index 4bb3ff5..c517b76 100644 --- a/src/client/components/SyncStatusIndicator.tsx +++ b/src/client/components/SyncStatusIndicator.tsx @@ -101,11 +101,15 @@ export function SyncStatusIndicator() { ); }; + const titleText = !isOnline + ? "Showing cached data — changes will sync when you're back online" + : lastError || undefined; + return ( <div data-testid="sync-status-indicator" className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${getStatusStyles()}`} - title={lastError || undefined} + title={titleText} > {getStatusIcon()} <span>{getStatusText()}</span> |
