Implement AI consent gating, user preference settings, and Ko-fi hint
This commit is contained in:
@@ -8,6 +8,7 @@ import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
|
||||
interface EventRemarksCellProps {
|
||||
event: LogEventPayload
|
||||
@@ -45,6 +46,13 @@ export default function EventRemarksCell({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||
if (!getAiAuthorized()) {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
setTranscribing(true)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
import {
|
||||
appendQuickEvent as apiAppendQuickEvent,
|
||||
appendQuickEvents as apiAppendQuickEvents,
|
||||
@@ -834,28 +835,32 @@ export default function LiveLogView({
|
||||
void (async () => {
|
||||
try {
|
||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||
|
||||
const authorized = getAiAuthorized()
|
||||
let transcriptionText = ''
|
||||
let transcribed = true
|
||||
let transcriptionError = false
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||
if (authorized) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||
|
||||
const res = await fetch('/api/ai/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioDataUrl }),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||
const data = await res.json()
|
||||
transcriptionText = (data.text || '').trim()
|
||||
} catch (err) {
|
||||
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||
transcriptionError = true
|
||||
const res = await fetch('/api/ai/transcribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audioDataUrl }),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||
const data = await res.json()
|
||||
transcriptionText = (data.text || '').trim()
|
||||
} catch (err) {
|
||||
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||
transcriptionError = true
|
||||
transcribed = false
|
||||
}
|
||||
} else {
|
||||
transcribed = false
|
||||
}
|
||||
|
||||
@@ -891,11 +896,16 @@ export default function LiveLogView({
|
||||
mode: 'auto'
|
||||
})
|
||||
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||
} else {
|
||||
} else if (authorized) {
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'success',
|
||||
mode: 'auto'
|
||||
})
|
||||
} else {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Live log voice save failed:', err)
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
TravelDaySummaryApiError
|
||||
} from '../services/aiSummary.js'
|
||||
import { tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
import {
|
||||
getDecryptedTrack,
|
||||
saveUploadedTrack,
|
||||
@@ -1209,6 +1210,13 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleGenerateAiSummary = async () => {
|
||||
if (!canSignSkipper || readOnly || aiSummaryLoading) return
|
||||
if (!getAiAuthorized()) {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!isOnline) {
|
||||
setAiSummaryError(t('logs.ai_summary_offline'))
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
||||
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from '../services/userPreferences.js'
|
||||
|
||||
interface UserProfilePreferencesProps {
|
||||
@@ -28,6 +30,7 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||
const [savingOwm, setSavingOwm] = useState(false)
|
||||
const [owmSaved, setOwmSaved] = useState(false)
|
||||
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
setThemePreference(userId, nextTheme)
|
||||
@@ -58,6 +61,12 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||
}
|
||||
|
||||
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextVal = e.target.checked
|
||||
setAiAuthorizedState(nextVal)
|
||||
setAiAuthorized(userId, nextVal)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="member-editor-card glass">
|
||||
@@ -152,6 +161,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('profile.ai_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||
{t('profile.ai_desc')}
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||
{t('profile.ai_help')}
|
||||
</p>
|
||||
|
||||
<label
|
||||
className="switch-label"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="profile-ai-authorize"
|
||||
type="checkbox"
|
||||
checked={aiAuthorized}
|
||||
onChange={handleAiToggle}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>{t('profile.ai_enable_label')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<PushNotificationSettings />
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user