aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/components
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 11:11:53 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 11:12:00 +0900
commit7ca9941982a7d7a4c126d215770ce71ad2f7f427 (patch)
tree0178b48094e9b7b143fd47c4d8479d3d588bb1d7 /src/client/components
parent8f1a08fefee3a8e928baec741c830a88a4cd7200 (diff)
downloadkioku-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.tsx6
-rw-r--r--src/client/components/CreateDeckModal.tsx6
-rw-r--r--src/client/components/CreateNoteModal.tsx8
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx6
-rw-r--r--src/client/components/DeleteCardModal.tsx6
-rw-r--r--src/client/components/DeleteDeckModal.tsx6
-rw-r--r--src/client/components/DeleteNoteModal.tsx6
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx6
-rw-r--r--src/client/components/EditCardModal.tsx6
-rw-r--r--src/client/components/EditDeckModal.tsx6
-rw-r--r--src/client/components/EditNoteModal.tsx8
-rw-r--r--src/client/components/EditNoteTypeModal.tsx6
-rw-r--r--src/client/components/ImportNotesModal.tsx6
-rw-r--r--src/client/components/SyncStatusIndicator.tsx6
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>