Empty default API key
Warning message in TranscriptionTask when API key is empty "Add API Key in Settings" button that opens settings modal Error message when trying to test empty API key No "Reset to Default" button for API key Improved copy functionality with inline success message Added fade-in animation for the success message
This commit is contained in:
parent
54db9d1904
commit
436c8132d3
12 changed files with 127 additions and 80 deletions
BIN
gemini/gemini-query-aarch64-apple-darwin
Executable file
BIN
gemini/gemini-query-aarch64-apple-darwin
Executable file
Binary file not shown.
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -3888,6 +3888,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64 0.21.7",
|
"base64 0.21.7",
|
||||||
|
"futures-util",
|
||||||
"image",
|
"image",
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ tauri = { version = "2.0.0", features = [] }
|
||||||
tokio = { version = "1.36", features = ["full"] }
|
tokio = { version = "1.36", features = ["full"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
futures-util = "0.3"
|
||||||
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-shell = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-dialog = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
tauri-plugin-fs = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v2" }
|
||||||
|
|
|
||||||
BIN
src-tauri/binaries/gemini-query-aarch64-apple-darwin
Executable file
BIN
src-tauri/binaries/gemini-query-aarch64-apple-darwin
Executable file
Binary file not shown.
|
|
@ -24,7 +24,13 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "shell:allow-execute",
|
"identifier": "shell:allow-execute",
|
||||||
"description": "Allows executing FFmpeg commands"
|
"description": "Allows executing FFmpeg commands and sidecars",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"name": "gemini-query",
|
||||||
|
"sidecar": true
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identifier": "core:event:allow-emit",
|
"identifier": "core:event:allow-emit",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
use tauri::{Runtime, State, Emitter, Manager};
|
use tauri::{Runtime, State, Emitter};
|
||||||
|
use tauri_plugin_shell::ShellExt;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
|
@ -174,30 +175,17 @@ async fn query_gemini<R: Runtime>(
|
||||||
println!("Image Path: {}", path);
|
println!("Image Path: {}", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
let executable_path = if cfg!(debug_assertions) {
|
let sidecar = app_handle.shell().sidecar("gemini-query")
|
||||||
// Development mode - use absolute path
|
.map_err(|e| Error::GeminiError(format!("Failed to get sidecar: {}", e)))?;
|
||||||
std::path::PathBuf::from("/Users/chaulmark/homemade-scripts/video-subtitle-merger/subtitle-merge/gemini/gemini-query")
|
|
||||||
} else {
|
let mut command = sidecar.arg(api_key).arg(prompt);
|
||||||
// Production mode - use resource path
|
|
||||||
app_handle.path().resource_dir()
|
|
||||||
.map_err(|e| Error::IoError(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::NotFound,
|
|
||||||
format!("Resource directory not found: {}", e)
|
|
||||||
)))?
|
|
||||||
.join("gemini-query")
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("Executable path: {:?}", executable_path);
|
|
||||||
let mut command = Command::new(executable_path);
|
|
||||||
command.arg(api_key)
|
|
||||||
.arg(prompt);
|
|
||||||
|
|
||||||
if let Some(path) = image_path {
|
if let Some(path) = image_path {
|
||||||
command.arg(path);
|
command = command.arg(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("\nExecuting command: {:?}", command);
|
println!("\nExecuting sidecar command");
|
||||||
let output = command.output()
|
let output = command.output().await
|
||||||
.map_err(|e| Error::GeminiError(format!("Failed to execute Gemini query: {}", e)))?;
|
.map_err(|e| Error::GeminiError(format!("Failed to execute Gemini query: {}", e)))?;
|
||||||
|
|
||||||
let response_text = String::from_utf8_lossy(&output.stdout);
|
let response_text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,13 @@
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["app"],
|
"targets": ["dmg", "app"],
|
||||||
|
"macOS": {
|
||||||
|
"frameworks": [],
|
||||||
|
"minimumSystemVersion": "10.13",
|
||||||
|
"signingIdentity": "Developer ID Application: Chris Haulmark (RJHWWWSF6Q)",
|
||||||
|
"providerShortName": null
|
||||||
|
},
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
|
|
@ -36,8 +42,8 @@
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
],
|
],
|
||||||
"resources": [
|
"externalBin": [
|
||||||
"../gemini/gemini-query"
|
"binaries/gemini-query"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ function App() {
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskType>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskType>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState('AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [visualPrompt, setVisualPrompt] = useState(() => {
|
const [visualPrompt, setVisualPrompt] = useState(() => {
|
||||||
const saved = localStorage.getItem('visualPrompt');
|
const saved = localStorage.getItem('visualPrompt');
|
||||||
return saved || DEFAULT_VISUAL_PROMPT;
|
return saved || DEFAULT_VISUAL_PROMPT;
|
||||||
|
|
@ -110,6 +110,7 @@ function App() {
|
||||||
apiKey={apiKey}
|
apiKey={apiKey}
|
||||||
visualPrompt={visualPrompt}
|
visualPrompt={visualPrompt}
|
||||||
subtitlePrompt={subtitlePrompt}
|
subtitlePrompt={subtitlePrompt}
|
||||||
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ interface SettingsProps {
|
||||||
|
|
||||||
// Note: We keep subtitle prompt in Settings since it's still used by the merge task
|
// Note: We keep subtitle prompt in Settings since it's still used by the merge task
|
||||||
|
|
||||||
export const DEFAULT_API_KEY = 'AIzaSyAF825tPTh77oL0knsGFEyvsN0iPUO_bXc';
|
export const DEFAULT_API_KEY = '';
|
||||||
export const DEFAULT_VISUAL_PROMPT = `Analyze this video frame from timestamp {} and provide a visual description focusing on:
|
export const DEFAULT_VISUAL_PROMPT = `Analyze this video frame from timestamp {} and provide a visual description focusing on:
|
||||||
1. The visual setup and environment
|
1. The visual setup and environment
|
||||||
2. The people present, their appearance, and positioning
|
2. The people present, their appearance, and positioning
|
||||||
|
|
@ -83,10 +83,6 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
setDisplayValue('*'.repeat(apiKey.length));
|
setDisplayValue('*'.repeat(apiKey.length));
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetApiKeyToDefault = () => {
|
|
||||||
onApiKeyChange(DEFAULT_API_KEY);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetVisualPromptToDefault = () => {
|
const resetVisualPromptToDefault = () => {
|
||||||
onVisualPromptChange(DEFAULT_VISUAL_PROMPT);
|
onVisualPromptChange(DEFAULT_VISUAL_PROMPT);
|
||||||
};
|
};
|
||||||
|
|
@ -98,6 +94,13 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
const testApiKey = async () => {
|
const testApiKey = async () => {
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
|
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
setTestResult('Error: API Key is required for testing');
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('test_gemini_api', { apiKey });
|
await invoke('test_gemini_api', { apiKey });
|
||||||
setTestResult('API key is valid');
|
setTestResult('API key is valid');
|
||||||
|
|
@ -145,6 +148,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
|
<a
|
||||||
|
href="https://aistudio.google.com/app/apikey"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Get API Key
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={testApiKey}
|
onClick={testApiKey}
|
||||||
disabled={testing}
|
disabled={testing}
|
||||||
|
|
@ -152,15 +163,6 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
>
|
>
|
||||||
{testing ? 'Testing...' : 'Test API Key'}
|
{testing ? 'Testing...' : 'Test API Key'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{apiKey !== DEFAULT_API_KEY && (
|
|
||||||
<button
|
|
||||||
onClick={resetApiKeyToDefault}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
Reset to Default
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{testResult && (
|
{testResult && (
|
||||||
|
|
@ -174,19 +176,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Visual Description Prompt
|
Visual Description Prompt
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<textarea
|
||||||
<textarea
|
value={visualPrompt}
|
||||||
value={visualPrompt}
|
onChange={(e) => onVisualPromptChange(e.target.value)}
|
||||||
onChange={(e) => onVisualPromptChange(e.target.value)}
|
rows={4}
|
||||||
rows={4}
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
|
||||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
|
placeholder="Enter prompt for visual description"
|
||||||
placeholder="Enter prompt for visual description"
|
spellCheck={false}
|
||||||
spellCheck={false}
|
/>
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 text-xs text-gray-500">
|
|
||||||
{visualPrompt.length} characters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{visualPrompt !== DEFAULT_VISUAL_PROMPT && (
|
{visualPrompt !== DEFAULT_VISUAL_PROMPT && (
|
||||||
<button
|
<button
|
||||||
onClick={resetVisualPromptToDefault}
|
onClick={resetVisualPromptToDefault}
|
||||||
|
|
@ -201,19 +198,14 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Subtitle Narrative Prompt
|
Subtitle Narrative Prompt
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<textarea
|
||||||
<textarea
|
value={subtitlePrompt}
|
||||||
value={subtitlePrompt}
|
onChange={(e) => onSubtitlePromptChange(e.target.value)}
|
||||||
onChange={(e) => onSubtitlePromptChange(e.target.value)}
|
rows={4}
|
||||||
rows={4}
|
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
|
||||||
className="w-full px-3 py-2 rounded-md border border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm font-mono resize-y"
|
placeholder="Enter prompt for subtitle narrative"
|
||||||
placeholder="Enter prompt for subtitle narrative"
|
spellCheck={false}
|
||||||
spellCheck={false}
|
/>
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 text-xs text-gray-500">
|
|
||||||
{subtitlePrompt.length} characters
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{subtitlePrompt !== DEFAULT_SUBTITLE_PROMPT && (
|
{subtitlePrompt !== DEFAULT_SUBTITLE_PROMPT && (
|
||||||
<button
|
<button
|
||||||
onClick={resetSubtitlePromptToDefault}
|
onClick={resetSubtitlePromptToDefault}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,14 @@ interface TranscriptionTaskProps {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
visualPrompt: string;
|
visualPrompt: string;
|
||||||
subtitlePrompt: string;
|
subtitlePrompt: string;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
|
export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
|
||||||
apiKey,
|
apiKey,
|
||||||
visualPrompt,
|
visualPrompt,
|
||||||
subtitlePrompt,
|
subtitlePrompt,
|
||||||
|
onOpenSettings
|
||||||
}) => {
|
}) => {
|
||||||
const [videoFile, setVideoFile] = useState<FileState | null>(null);
|
const [videoFile, setVideoFile] = useState<FileState | null>(null);
|
||||||
const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null);
|
const [subtitleFile, setSubtitleFile] = useState<FileState | null>(null);
|
||||||
|
|
@ -44,6 +46,7 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
|
||||||
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
|
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
|
||||||
const [transcription, setTranscription] = useState<TranscriptionState | null>(null);
|
const [transcription, setTranscription] = useState<TranscriptionState | null>(null);
|
||||||
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
|
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
const handleVideoSelect = (path: string) => {
|
const handleVideoSelect = (path: string) => {
|
||||||
const name = path.split('/').pop() || '';
|
const name = path.split('/').pop() || '';
|
||||||
|
|
@ -105,6 +108,28 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{!apiKey && (
|
||||||
|
<div className="bg-amber-50 border-l-4 border-amber-400 p-4 mb-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-amber-700">
|
||||||
|
A Gemini API Key is required to generate video descriptions.
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="ml-2 font-medium text-amber-700 underline hover:text-amber-600"
|
||||||
|
>
|
||||||
|
Add API Key in Settings
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1 mr-4">
|
<div className="flex-1 mr-4">
|
||||||
|
|
@ -149,9 +174,32 @@ export const TranscriptionTask: React.FC<TranscriptionTaskProps> = ({
|
||||||
{transcription && (
|
{transcription && (
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Scene Narrative</h3>
|
<h3 className="text-lg font-semibold">Scene Narrative</h3>
|
||||||
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm">
|
<div className="space-y-2">
|
||||||
<div className="bg-white p-4 rounded border border-gray-200">
|
<div className="h-[400px] overflow-y-auto p-4 bg-gray-50 rounded-lg text-sm">
|
||||||
<p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
|
<div className="bg-white p-4 rounded border border-gray-200">
|
||||||
|
<p className="whitespace-pre-wrap leading-relaxed">{transcription.narrative}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // Prevent any navigation
|
||||||
|
navigator.clipboard.writeText(transcription.narrative)
|
||||||
|
.then(() => {
|
||||||
|
setCopySuccess(true);
|
||||||
|
setTimeout(() => setCopySuccess(false), 2000);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Failed to copy:', err));
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
Copy to Clipboard
|
||||||
|
</button>
|
||||||
|
{copySuccess && (
|
||||||
|
<span className="text-sm text-green-600 animate-fade-in">
|
||||||
|
Content copied successfully!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,8 @@ interface SelectedFrame {
|
||||||
|
|
||||||
export const VideoTimeline: React.FC<VideoTimelineProps> = ({ videoPath, onFramesSelect, thumbnails }) => {
|
export const VideoTimeline: React.FC<VideoTimelineProps> = ({ videoPath, onFramesSelect, thumbnails }) => {
|
||||||
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
|
const [selectedFrames, setSelectedFrames] = useState<SelectedFrame[]>([]);
|
||||||
const [duration, setDuration] = useState<number>(0);
|
|
||||||
const [thumbnailUrls, setThumbnailUrls] = useState<{ [key: string]: string }>({});
|
const [thumbnailUrls, setThumbnailUrls] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (videoPath) {
|
|
||||||
invoke<number>('get_video_duration', { path: videoPath })
|
|
||||||
.then(videoDuration => setDuration(videoDuration))
|
|
||||||
.catch(error => console.error('Error getting video duration:', error));
|
|
||||||
} else {
|
|
||||||
setDuration(0);
|
|
||||||
}
|
|
||||||
}, [videoPath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadThumbnails = async () => {
|
const loadThumbnails = async () => {
|
||||||
const urls: { [key: string]: string } = {};
|
const urls: { [key: string]: string } = {};
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,18 @@
|
||||||
|
|
||||||
/* Remove unused progress bar styles since we're using direct classes in App.tsx */
|
/* Remove unused progress bar styles since we're using direct classes in App.tsx */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.2s ease-in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue