+41
-17
app/(home)/text-to-speech/history.tsx
+41
-17
app/(home)/text-to-speech/history.tsx
···
5
5
import { getDateString, getTimeAgo } from "@/utils/time";
6
6
import { actor, getVoices } from "@/utils/tts";
7
7
import { motion } from "framer-motion";
8
+
import { useEffect, useState } from "react";
8
9
import {
9
-
useEffect,
10
-
useRef
11
-
} from "react";
12
-
import { HiPencil, HiPlay, HiRefresh, HiSpeakerphone } from "react-icons/hi";
10
+
HiDownload,
11
+
HiPencil, HiPlay, HiSpeakerphone
12
+
} from "react-icons/hi";
13
+
import { HiTrash } from "react-icons/hi2";
13
14
14
-
import { HistoryItem, State } from "./use-history";
15
+
import { type HistoryItem, State } from "./use-history";
15
16
16
17
const springAnimation = {
17
18
hidden: { y: 20, opacity: 0 },
···
33
34
ensureUrl,
34
35
fallbackVoice,
35
36
onPlay,
36
-
onRefill
37
+
onRefill,
38
+
onDelete
37
39
}: {
38
40
history: HistoryItem[];
39
41
error: string | null;
···
42
44
fallbackVoice: string;
43
45
onPlay: (url: string) => void;
44
46
onRefill: (text: string, voice: string) => void;
47
+
onDelete: (id: number) => void;
45
48
}) {
46
-
const init = useRef(false);
49
+
const [hasAnimated, setHasAnimated] = useState(false);
50
+
const isLoading = state === State.Loading;
51
+
const shouldAnimate = !hasAnimated && !isLoading && history.length > 0;
47
52
48
53
useEffect(() => {
49
-
if (state === State.Loading) return;
50
-
init.current = true;
51
-
52
-
return () => {
53
-
init.current = false;
54
-
};
55
-
}, [state]);
54
+
if (hasAnimated || isLoading) return;
55
+
if (history.length) {
56
+
queueMicrotask(() => setHasAnimated(true));
57
+
}
58
+
}, [hasAnimated, history.length, isLoading]);
56
59
57
60
if (error) {
58
61
return <div className="text-sm text-red-400">{error}</div>;
59
62
}
60
63
61
-
if (state === State.Loading) {
64
+
if (isLoading) {
62
65
return (
63
66
<div className="flex flex-col gap-3">
64
67
{Array.from({ length: 3 }).map((_, i) => (
···
90
93
}
91
94
}
92
95
}}
93
-
initial={init.current ? "visible" : "hidden"}
94
-
animate="visible"
96
+
initial={shouldAnimate ? "hidden" : false}
97
+
animate={shouldAnimate ? "visible" : false}
95
98
className="flex flex-col gap-3"
96
99
>
97
100
{history.map((item) => (
···
101
104
fallbackVoice={fallbackVoice}
102
105
onPlay={onPlay}
103
106
onRefill={onRefill}
107
+
onDelete={onDelete}
104
108
ensureUrl={ensureUrl}
105
109
/>
106
110
))}
···
114
118
fallbackVoice,
115
119
onPlay,
116
120
onRefill,
121
+
onDelete,
117
122
ensureUrl
118
123
}: {
119
124
item: HistoryItem;
120
125
fallbackVoice: string;
121
126
onPlay: (url: string) => void;
122
127
onRefill: (text: string, voice: string) => void;
128
+
onDelete: (id: number) => void;
123
129
ensureUrl: (item: HistoryItem) => string;
124
130
}) {
125
131
const createdAt = new Date(item.createdAt);
···
160
166
}}
161
167
>
162
168
<HiPencil className="mr-1" /> Edit
169
+
</Button>
170
+
<Button
171
+
className="p-4.5"
172
+
size="icon"
173
+
variant="ghost"
174
+
onClick={() => onDelete(item.id)}
175
+
>
176
+
<HiTrash />
177
+
</Button>
178
+
<Button
179
+
className="p-4.5"
180
+
size="icon"
181
+
variant="ghost"
182
+
asChild
183
+
>
184
+
<a href={ensureUrl(item)} download>
185
+
<HiDownload />
186
+
</a>
163
187
</Button>
164
188
</div>
165
189
</motion.div>
+16
-7
app/(home)/text-to-speech/page.tsx
+16
-7
app/(home)/text-to-speech/page.tsx
···
15
15
import type { TurnstileInstance } from "@marsidev/react-turnstile";
16
16
import { Turnstile } from "@marsidev/react-turnstile";
17
17
import { useRef, useState } from "react";
18
+
import { HiDownload } from "react-icons/hi";
18
19
19
20
import { getSpeech } from "./api";
20
21
import { History } from "./history";
···
30
31
const [loading, setLoading] = useState(false);
31
32
const [error, setError] = useState<string | null>(null);
32
33
33
-
const { history, error: historyError, state, addEntry, ensureUrl } = useHistory();
34
+
const { history, error: historyError, state, addEntry, deleteEntry, ensureUrl } = useHistory();
34
35
const captcha = useRef<TurnstileInstance>(null);
35
36
36
37
const handleGenerate = async () => {
···
128
129
<div className="rounded-xl bg-linear-to-br from-violet-300/8 to-violet-200/5 p-5 mt-4">
129
130
<AudioPlayerProvider>
130
131
<div className="flex items-center gap-4">
131
-
<AudioPlayerButton
132
-
item={{
133
-
id: audioUrl,
134
-
src: audioUrl
135
-
}}
136
-
/>
132
+
<div className="flex items-center gap-2">
133
+
<Button asChild>
134
+
<a href={audioUrl} download>
135
+
<HiDownload />
136
+
</a>
137
+
</Button>
138
+
<AudioPlayerButton
139
+
item={{
140
+
id: audioUrl,
141
+
src: audioUrl
142
+
}}
143
+
/>
144
+
</div>
137
145
<AudioPlayerProgress className="flex-1" />
138
146
<AudioPlayerTime />
139
147
<span>/</span>
···
161
169
setText(newText);
162
170
setVoice(newVoice);
163
171
}}
172
+
onDelete={(id) => deleteEntry(id)}
164
173
/>
165
174
</div>
166
175
</div>
+23
-7
app/(home)/text-to-speech/use-history.ts
+23
-7
app/(home)/text-to-speech/use-history.ts
···
24
24
};
25
25
26
26
export function useHistory() {
27
-
const [state, setState] = useState<State>(State.Loading);
27
+
const isIndexDbSupported = typeof window !== "undefined" && "indexedDB" in window;
28
+
29
+
const [state, setState] = useState<State>(() => isIndexDbSupported ? State.Loading : State.Success);
28
30
const [history, setHistory] = useState<HistoryItem[]>([]);
29
-
const [error, setError] = useState<string | null>(null);
31
+
const [error, setError] = useState<string | null>(() => isIndexDbSupported ? null : "IndexedDB is not available in this browser.");
30
32
31
33
const dbRef = useRef<IDBDatabase | null>(null);
32
34
const urlCache = useRef<Map<number, string>>(new Map());
···
34
36
useEffect(() => {
35
37
let mounted = true;
36
38
37
-
if (typeof window === "undefined" || !("indexedDB" in window)) {
38
-
setError("IndexedDB is not available in this browser.");
39
-
return;
40
-
}
39
+
if (!isIndexDbSupported) return;
41
40
42
41
const openDb = () => new Promise<IDBDatabase>((resolve, reject) => {
43
42
const request = indexedDB.open(DB_NAME, DB_VERSION);
···
87
86
dbRef.current?.close();
88
87
for (const [, url] of urlCache.current) URL.revokeObjectURL(url);
89
88
};
90
-
}, []);
89
+
}, [isIndexDbSupported]);
91
90
92
91
const addEntry = async (entry: Omit<HistoryItem, "id">, cachedUrl?: string) => {
93
92
const db = dbRef.current;
···
113
112
return persistId;
114
113
};
115
114
115
+
const deleteEntry = async (id: number) => {
116
+
const db = dbRef.current;
117
+
if (!db) return;
118
+
119
+
const tx = db.transaction(STORE_NAME, "readwrite");
120
+
const store = tx.objectStore(STORE_NAME);
121
+
const request = store.delete(id);
122
+
123
+
await new Promise<void>((resolve, reject) => {
124
+
request.onsuccess = () => resolve();
125
+
request.onerror = () => reject(request.error);
126
+
});
127
+
128
+
setHistory((prev) => prev.filter((item) => item.id !== id));
129
+
};
130
+
116
131
const ensureUrl = (item: HistoryItem) => {
117
132
if (urlCache.current.has(item.id)) return urlCache.current.get(item.id)!;
118
133
const url = base64ToUrl(item.base64);
···
125
140
error,
126
141
state,
127
142
addEntry,
143
+
deleteEntry,
128
144
ensureUrl
129
145
};
130
146
}
+3
-3
components/ui/audio-player.tsx
+3
-3
components/ui/audio-player.tsx
···
9
9
} from "@/components/ui/dropdown-menu";
10
10
import { cn } from "@/utils/cn";
11
11
import * as SliderPrimitive from "@radix-ui/react-slider";
12
-
import { Check, PauseIcon, PlayIcon, Settings } from "lucide-react";
12
+
import { Check, Settings } from "lucide-react";
13
13
import type {
14
14
ComponentProps,
15
15
HTMLProps,
···
70
70
isBuffering: boolean;
71
71
playbackRate: number;
72
72
isItemActive: (id: string | number | null) => boolean;
73
-
setActiveItem: (item: AudioPlayerItem<TData> | null) => Promise<void>;
73
+
setActiveItem: (item: AudioPlayerItem<TData> | null) => void;
74
74
play: (item?: AudioPlayerItem<TData> | null) => Promise<void>;
75
75
pause: () => void;
76
76
seek: (time: number) => void;
···
121
121
const [playbackRate, setPlaybackRateState] = useState<number>(1);
122
122
123
123
const setActiveItem = useCallback(
124
-
async (item: AudioPlayerItem<TData> | null) => {
124
+
(item: AudioPlayerItem<TData> | null) => {
125
125
if (!audioRef.current) return;
126
126
127
127
if (item?.id === itemRef.current?.id) {
+1
-1
public/docs/text-to-speech.md
+1
-1
public/docs/text-to-speech.md
···
19
19
<br />
20
20
<br />
21
21
22
-
To get a quick **.mp3 file** of your message, use `/tts file` in any text channel.
22
+
To get a quick **.mp3 file** of your message, use `/tts file` in any text channel. You can also [generate Text to Speech files online](/text-to-speech).
23
23
24
24
### 📑 Usage logs
25
25
Pick a channel where any Text to Speech events from your server should be logged, mainly for moderation purposes.