Skip to content

Commit 202e800

Browse files
committed
allow for custom url repositories (locally hosted strudel files)
1 parent 6c268b3 commit 202e800

File tree

8 files changed

+351
-25
lines changed

8 files changed

+351
-25
lines changed

src/components/ExportImport.tsx

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ export function ExportImport() {
1111
const savedRepositories = useSoundStore((state) => state.savedRepositories);
1212
const importRepositories = useSoundStore((state) => state.importRepositories);
1313
const importBlocklist = useSoundStore((state) => state.importBlocklist);
14+
const importCustomUrls = useSoundStore((state) => state.importCustomUrls);
1415
const getBlockedRepos = useSoundStore((state) => state.getBlockedRepos);
16+
const getCustomUrls = useSoundStore((state) => state.getCustomUrls);
1517
const clearPreviews = useSoundStore((state) => state.clearPreviews);
1618
const previewRepositories = useSoundStore((state) => state.previewRepositories);
1719
const toast = useToast();
@@ -23,8 +25,17 @@ export function ExportImport() {
2325
}
2426

2527
const blocklist = getBlockedRepos();
26-
downloadExport(savedRepositories, blocklist);
27-
toast.success(`Exported ${savedRepositories.length} saved repositories and ${blocklist.length} blocked repositories!`);
28+
const customUrls = getCustomUrls();
29+
downloadExport(savedRepositories, blocklist, customUrls);
30+
31+
let successMessage = `Exported ${savedRepositories.length} saved repositories`;
32+
if (blocklist.length > 0) {
33+
successMessage += `, ${blocklist.length} blocked repositories`;
34+
}
35+
if (customUrls.length > 0) {
36+
successMessage += `, ${customUrls.length} custom URLs`;
37+
}
38+
toast.success(successMessage + '!');
2839
};
2940

3041
const handleImport = async (e: ChangeEvent<HTMLInputElement>) => {
@@ -56,10 +67,19 @@ export function ExportImport() {
5667
importBlocklist(data.blocklist);
5768
}
5869

59-
const blocklistMsg = data.blocklist && data.blocklist.length > 0
60-
? ` and ${data.blocklist.length} blocked repositories`
61-
: '';
62-
toast.success(`Successfully imported ${data.repositories.length} repositories${blocklistMsg}!`);
70+
// Import custom URLs if present
71+
if (data.customUrls && data.customUrls.length > 0) {
72+
importCustomUrls(data.customUrls);
73+
}
74+
75+
let successMessage = `Successfully imported ${data.repositories.length} repositories`;
76+
if (data.blocklist && data.blocklist.length > 0) {
77+
successMessage += `, ${data.blocklist.length} blocked repositories`;
78+
}
79+
if (data.customUrls && data.customUrls.length > 0) {
80+
successMessage += `, ${data.customUrls.length} custom URLs`;
81+
}
82+
toast.success(successMessage + '!');
6383
setPendingImport(null);
6484
} catch (error) {
6585
toast.error(`Failed to import: ${error instanceof Error ? error.message : 'Unknown error'}`);

src/components/SavedRepositoryCard.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ export function SavedRepositoryCard({ repository, onPlay, onStop }: SavedReposit
4343
return groupSoundsByCategory(repository.sounds);
4444
}, [repository.sounds]);
4545

46-
const repoUrl = `https://github.com/${repository.owner}/${repository.repo}`;
46+
// Generate repository URL (handle custom URLs differently)
47+
const repoUrl = repository.isCustomUrl
48+
? repository.strudel_json_url
49+
: `https://github.com/${repository.owner}/${repository.repo}`;
4750

4851
return (
4952
<div className="bg-white border border-gray-200 rounded-lg p-3 hover:shadow-md transition-shadow">
@@ -68,7 +71,11 @@ export function SavedRepositoryCard({ repository, onPlay, onStop }: SavedReposit
6871
</div>
6972

7073
<div className="bg-purple-50 px-2 py-1 rounded text-xs">
71-
<span className="text-purple-700 font-mono">samples('github:{repository.owner}/{repository.repo}')</span>
74+
<span className="text-purple-700 font-mono">
75+
{repository.isCustomUrl
76+
? `samples('${repository.raw_json_url}')`
77+
: `samples('github:${repository.owner}/${repository.repo}')`}
78+
</span>
7279
</div>
7380
</div>
7481

src/components/Settings.tsx

Lines changed: 147 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useState, useEffect, useRef } from 'react';
2+
import type { FormEvent } from 'react';
23
import { setGitHubToken, clearGitHubToken, hasGitHubToken } from '../utils/github-api';
34
import { useToast } from '../hooks/useToast';
45
import { useSoundStore } from '../store/soundStore';
6+
import { useStrudelJson } from '../hooks/useStrudelJson';
7+
import type { CustomUrlRepository } from '../types/strudel';
58

69
interface SettingsProps {
710
onClose: () => void;
@@ -15,16 +18,22 @@ export function Settings({ onClose }: SettingsProps) {
1518
const [showClearBlocklistConfirm, setShowClearBlocklistConfirm] = useState(false);
1619
const [hasTokenState, setHasTokenState] = useState(false);
1720
const [blockedReposList, setBlockedReposList] = useState<string[]>([]);
21+
const [customUrlsList, setCustomUrlsList] = useState<CustomUrlRepository[]>([]);
22+
const [newCustomUrl, setNewCustomUrl] = useState('');
23+
const [newCustomUrlName, setNewCustomUrlName] = useState('');
24+
const [isLoadingCustomUrl, setIsLoadingCustomUrl] = useState(false);
25+
const [customUrlError, setCustomUrlError] = useState<string | null>(null);
1826

1927
// Refs to store functions without causing re-renders
2028
const storeRef = useRef({
21-
unblockRepository: (key: string) => {
22-
console.info('Placeholder unblockRepository called with key:', key);
23-
},
24-
clearBlocklist: () => {}
29+
unblockRepository: (_key: string) => {},
30+
clearBlocklist: () => {},
31+
removeCustomUrl: (_url: string) => {},
32+
getCustomUrls: () => [] as CustomUrlRepository[]
2533
});
2634

2735
const toast = useToast();
36+
const { loadCustomUrlRepository } = useStrudelJson();
2837

2938
// Initialize state on mount only
3039
useEffect(() => {
@@ -35,17 +44,23 @@ export function Settings({ onClose }: SettingsProps) {
3544
const store = useSoundStore.getState();
3645
storeRef.current = {
3746
unblockRepository: store.unblockRepository,
38-
clearBlocklist: store.clearBlocklist
47+
clearBlocklist: store.clearBlocklist,
48+
removeCustomUrl: store.removeCustomUrl,
49+
getCustomUrls: store.getCustomUrls
3950
};
4051

4152
// Get blocked repos
4253
setBlockedReposList(store.getBlockedRepos());
4354

55+
// Get custom URLs
56+
setCustomUrlsList(store.getCustomUrls());
57+
4458
// Subscribe to blockedRepos changes
4559
const unsubscribe = useSoundStore.subscribe(
4660
(state) => {
47-
// When blockedRepos changes, update our local state
61+
// When blockedRepos or customUrls change, update our local state
4862
setBlockedReposList(state.getBlockedRepos());
63+
setCustomUrlsList(state.getCustomUrls());
4964
}
5065
);
5166

@@ -78,6 +93,46 @@ export function Settings({ onClose }: SettingsProps) {
7893
toast.success('Blocklist cleared');
7994
setShowClearBlocklistConfirm(false);
8095
};
96+
97+
const handleAddCustomUrl = async (e: FormEvent) => {
98+
e.preventDefault();
99+
100+
if (!newCustomUrl.trim() || !newCustomUrlName.trim()) {
101+
toast.error('Please enter both URL and name');
102+
return;
103+
}
104+
105+
// Validate URL format
106+
try {
107+
new URL(newCustomUrl);
108+
} catch (_err) {
109+
toast.error('Please enter a valid URL');
110+
return;
111+
}
112+
113+
setIsLoadingCustomUrl(true);
114+
setCustomUrlError(null);
115+
116+
try {
117+
// Load the repository (it will be automatically saved)
118+
await loadCustomUrlRepository(newCustomUrl, newCustomUrlName);
119+
120+
toast.success(`Added custom URL: ${newCustomUrlName}`);
121+
setNewCustomUrl('');
122+
setNewCustomUrlName('');
123+
} catch (err) {
124+
const message = err instanceof Error ? err.message : 'Failed to load custom URL';
125+
setCustomUrlError(message);
126+
toast.error(message);
127+
} finally {
128+
setIsLoadingCustomUrl(false);
129+
}
130+
};
131+
132+
const handleRemoveCustomUrl = (url: string) => {
133+
storeRef.current.removeCustomUrl(url);
134+
toast.info('Custom URL removed');
135+
};
81136

82137
return (
83138
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50 overflow-y-auto">
@@ -206,6 +261,92 @@ export function Settings({ onClose }: SettingsProps) {
206261
</>
207262
)}
208263
</div>
264+
265+
{/* Custom URLs Section */}
266+
<div className="border-t pt-4">
267+
<h3 className="font-semibold text-gray-800 mb-3">
268+
🔗 Custom URLs ({customUrlsList.length})
269+
</h3>
270+
<p className="text-sm text-gray-600 mb-3">
271+
Add custom strudel.json URLs from any source, including localhost for development.
272+
</p>
273+
274+
<form onSubmit={handleAddCustomUrl} className="mb-4 space-y-3">
275+
<div>
276+
<label htmlFor="customUrl" className="block text-sm font-medium text-gray-700 mb-1">
277+
Strudel JSON URL
278+
</label>
279+
<input
280+
id="customUrl"
281+
type="text"
282+
value={newCustomUrl}
283+
onChange={(e) => setNewCustomUrl(e.target.value)}
284+
placeholder="https://example.com/strudel.json"
285+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-transparent"
286+
disabled={isLoadingCustomUrl}
287+
/>
288+
</div>
289+
290+
<div>
291+
<label htmlFor="customUrlName" className="block text-sm font-medium text-gray-700 mb-1">
292+
Display Name
293+
</label>
294+
<input
295+
id="customUrlName"
296+
type="text"
297+
value={newCustomUrlName}
298+
onChange={(e) => setNewCustomUrlName(e.target.value)}
299+
placeholder="My Custom Strudel"
300+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-purple-500 focus:border-transparent"
301+
disabled={isLoadingCustomUrl}
302+
/>
303+
</div>
304+
305+
{customUrlError && (
306+
<div className="text-sm text-red-600">
307+
Error: {customUrlError}
308+
</div>
309+
)}
310+
311+
<button
312+
type="submit"
313+
disabled={isLoadingCustomUrl || !newCustomUrl.trim() || !newCustomUrlName.trim()}
314+
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
315+
>
316+
{isLoadingCustomUrl ? 'Loading...' : '➕ Add Custom URL'}
317+
</button>
318+
</form>
319+
320+
{customUrlsList.length === 0 ? (
321+
<div className="bg-gray-50 border border-gray-200 rounded p-3 text-sm text-gray-600 text-center">
322+
No custom URLs added
323+
</div>
324+
) : (
325+
<div className="bg-gray-50 border border-gray-200 rounded max-h-40 overflow-y-auto">
326+
{customUrlsList.map((item) => (
327+
<div
328+
key={item.url}
329+
className="flex items-center justify-between p-2 border-b border-gray-200 last:border-b-0 hover:bg-gray-100"
330+
>
331+
<div className="flex-1 overflow-hidden">
332+
<div className="text-sm font-medium text-gray-800 truncate">
333+
{item.name}
334+
</div>
335+
<div className="text-xs text-gray-500 truncate font-mono">
336+
{item.url}
337+
</div>
338+
</div>
339+
<button
340+
onClick={() => handleRemoveCustomUrl(item.url)}
341+
className="ml-2 px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 transition-colors flex-shrink-0"
342+
>
343+
Remove
344+
</button>
345+
</div>
346+
))}
347+
</div>
348+
)}
349+
</div>
209350

210351
<div className="flex gap-3 pt-4 border-t">
211352
<button

src/hooks/useStrudelJson.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { useState, useCallback } from 'react';
2-
import { fetchStrudelJson, buildRawUrl } from '../utils/github-api';
2+
import { fetchStrudelJson, buildRawUrl, fetchCustomUrlContent } from '../utils/github-api';
33
import { extractSounds } from '../utils/sound-processor';
44
import { useSoundStore } from '../store/soundStore';
55
import type { GitHubCodeSearchItem } from '../types/github';
6-
import type { LoadedRepository } from '../types/strudel';
6+
import type { LoadedRepository, StrudelJson } from '../types/strudel';
77

88
interface UseStrudelJsonResult {
99
isLoading: boolean;
1010
error: string | null;
1111
loadRepository: (item: GitHubCodeSearchItem) => Promise<void>;
12+
loadCustomUrlRepository: (url: string, name: string) => Promise<void>;
1213
}
1314

1415
export function useStrudelJson(): UseStrudelJsonResult {
1516
const [isLoading, setIsLoading] = useState(false);
1617
const [error, setError] = useState<string | null>(null);
1718
const addPreview = useSoundStore((state) => state.addPreview);
19+
const saveRepository = useSoundStore((state) => state.saveRepository);
20+
const addCustomUrl = useSoundStore((state) => state.addCustomUrl);
1821

1922
const loadRepository = useCallback(async (item: GitHubCodeSearchItem) => {
2023
setIsLoading(true);
@@ -59,9 +62,60 @@ export function useStrudelJson(): UseStrudelJsonResult {
5962
}
6063
}, [addPreview]);
6164

65+
const loadCustomUrlRepository = useCallback(async (url: string, name: string) => {
66+
setIsLoading(true);
67+
setError(null);
68+
69+
try {
70+
// Fetch the JSON content from the custom URL
71+
const content = await fetchCustomUrlContent(url);
72+
73+
// Parse the JSON content
74+
const strudelJson: StrudelJson = JSON.parse(content);
75+
76+
// Use custom values for owner and repo to identify this as a custom URL
77+
const owner = 'custom';
78+
const repo = name.replace(/\s+/g, '-').toLowerCase();
79+
const path = 'strudel.json';
80+
81+
// Extract sounds
82+
const sounds = extractSounds(strudelJson, owner, repo, path);
83+
84+
// Create loaded repository object
85+
const loadedRepo: LoadedRepository = {
86+
owner,
87+
repo,
88+
path,
89+
branch: 'main',
90+
strudel_json_url: url,
91+
raw_json_url: url,
92+
sounds,
93+
loaded_at: new Date().toISOString(),
94+
isCustomUrl: true
95+
};
96+
97+
// Add to preview store
98+
addPreview(loadedRepo);
99+
100+
// Also save it directly to saved repositories
101+
saveRepository(loadedRepo);
102+
103+
// Save the custom URL for future use
104+
addCustomUrl(url, name);
105+
106+
} catch (err) {
107+
const message = err instanceof Error ? err.message : 'Failed to load custom URL';
108+
setError(message);
109+
throw err;
110+
} finally {
111+
setIsLoading(false);
112+
}
113+
}, [addPreview, saveRepository, addCustomUrl]);
114+
62115
return {
63116
isLoading,
64117
error,
65118
loadRepository,
119+
loadCustomUrlRepository
66120
};
67121
}

0 commit comments

Comments
 (0)