| import React from 'react'; |
|
|
| interface HitokotoItem { |
| id: number; |
| uuid: string; |
| hitokoto: string; |
| type: string; |
| from: string; |
| from_who?: string | null; |
| } |
|
|
| const API_URL = 'https://v1.hitokoto.cn/?c=d&c=i&c=k&encode=json'; |
|
|
| const LOCAL_HIDE_KEY = 'hitokoto_hide'; |
| const LOCAL_LAST_KEY = 'hitokoto_last'; |
| const LOCAL_LAST_AT = 'hitokoto_last_at'; |
|
|
| const cacheMs = 5 * 60 * 1000; |
|
|
| const HitokotoBar: React.FC = () => { |
| const [hidden, setHidden] = React.useState<boolean>(() => localStorage.getItem(LOCAL_HIDE_KEY) === '1'); |
| const [loading, setLoading] = React.useState<boolean>(false); |
| const [error, setError] = React.useState<string>(''); |
| const [quote, setQuote] = React.useState<HitokotoItem | null>(() => { |
| try { |
| const last = localStorage.getItem(LOCAL_LAST_KEY); |
| const at = Number(localStorage.getItem(LOCAL_LAST_AT) || '0'); |
| if (last && Date.now() - at < cacheMs) { |
| return JSON.parse(last) as HitokotoItem; |
| } |
| } catch {} |
| return null; |
| }); |
|
|
| const fetchQuote = async () => { |
| try { |
| setLoading(true); |
| setError(''); |
| const res = await fetch(API_URL, { method: 'GET' }); |
| if (!res.ok) throw new Error('Network error'); |
| const data = await res.json(); |
| setQuote(data as HitokotoItem); |
| try { |
| localStorage.setItem(LOCAL_LAST_KEY, JSON.stringify(data)); |
| localStorage.setItem(LOCAL_LAST_AT, String(Date.now())); |
| } catch {} |
| } catch (e: any) { |
| setError('Failed to load quote'); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| React.useEffect(() => { |
| if (!hidden && !quote) { |
| fetchQuote(); |
| } |
| }, [hidden, quote]); |
|
|
| if (hidden) return null; |
|
|
| return ( |
| <div className="fixed bottom-0 right-0 z-50 w-[calc(100%-240px)] md:w-[calc(100%-240px)]"> |
| <div className="ml-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-3"> |
| <div className="bg-ui-panel border border-ui-border shadow-md rounded-lg px-4 py-2 flex items-center justify-between"> |
| <div className="flex items-center min-w-0"> |
| <span className="mr-2 text-ui-navy">💬</span> |
| <div className="min-w-0"> |
| {loading ? ( |
| <div className="text-sm text-ui-text/70">Loading…</div> |
| ) : error ? ( |
| <div className="text-sm text-red-400">{error}</div> |
| ) : ( |
| <div className="text-sm text-ui-text truncate"> |
| {quote ? `『${quote.hitokoto}』 — ${quote.from_who || quote.from || 'Hitokoto'}` : ' '} |
| </div> |
| )} |
| </div> |
| </div> |
| <div className="flex items-center space-x-2 flex-shrink-0"> |
| <button |
| onClick={fetchQuote} |
| className="text-xs px-2 py-1 rounded bg-ui-panel hover:bg-ui-panel/80 border border-ui-border text-ui-text" |
| title="Next quote" |
| > |
| Next |
| </button> |
| <button |
| onClick={() => { setHidden(true); localStorage.setItem(LOCAL_HIDE_KEY, '1'); }} |
| className="text-xs px-2 py-1 rounded bg-ui-panel hover:bg-ui-panel/80 border border-ui-border text-ui-text" |
| title="Hide" |
| > |
| Hide |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default HitokotoBar; |
|
|
|
|
|
|