agentcraft 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +7 -0
- package/README.md +95 -0
- package/bin/agentcraft.js +12 -0
- package/commands/agentcraft.md +46 -0
- package/hooks/hooks.json +13 -0
- package/hooks/play-sound.sh +69 -0
- package/opencode.js +87 -0
- package/package.json +19 -0
- package/screenshot.jpg +0 -0
- package/social-share-og.jpg +0 -0
- package/social-share-tw.jpg +0 -0
- package/social-share.png +0 -0
- package/web/README.md +36 -0
- package/web/app/api/agents/[name]/route.ts +44 -0
- package/web/app/api/agents/route.ts +62 -0
- package/web/app/api/assignments/route.ts +53 -0
- package/web/app/api/audio/[...path]/route.ts +35 -0
- package/web/app/api/health/route.ts +5 -0
- package/web/app/api/preview/route.ts +22 -0
- package/web/app/api/skills/route.ts +83 -0
- package/web/app/api/sounds/route.ts +106 -0
- package/web/app/api/ui-sounds/route.ts +44 -0
- package/web/app/favicon.ico +0 -0
- package/web/app/globals.css +99 -0
- package/web/app/icon.svg +13 -0
- package/web/app/layout.tsx +19 -0
- package/web/app/opengraph-image.tsx +96 -0
- package/web/app/page.tsx +268 -0
- package/web/bun.lock +292 -0
- package/web/components/agent-form.tsx +190 -0
- package/web/components/agent-roster-panel.tsx +502 -0
- package/web/components/assignment-log-panel.tsx +109 -0
- package/web/components/hook-slot.tsx +149 -0
- package/web/components/hud-header.tsx +135 -0
- package/web/components/sound-browser-panel.tsx +206 -0
- package/web/components/sound-unit.tsx +203 -0
- package/web/components/ui-sounds-modal.tsx +308 -0
- package/web/lib/types.ts +87 -0
- package/web/lib/ui-audio.ts +126 -0
- package/web/lib/utils.ts +98 -0
- package/web/next.config.ts +8 -0
- package/web/package.json +37 -0
- package/web/postcss.config.mjs +1 -0
- package/web/public/file.svg +1 -0
- package/web/public/fonts/starcraft-normal.ttf +0 -0
- package/web/public/globe.svg +1 -0
- package/web/public/next.svg +1 -0
- package/web/public/vercel.svg +1 -0
- package/web/public/window.svg +1 -0
- package/web/tsconfig.json +34 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getEventLabel } from '@/lib/utils';
|
|
4
|
+
import type { SoundAssignments, HookEvent } from '@/lib/types';
|
|
5
|
+
|
|
6
|
+
interface AssignmentEntry {
|
|
7
|
+
scope: string;
|
|
8
|
+
event: HookEvent;
|
|
9
|
+
sound: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface AssignmentLogPanelProps {
|
|
13
|
+
assignments: SoundAssignments;
|
|
14
|
+
isDirty: boolean;
|
|
15
|
+
onClear: (scope: string, event: HookEvent) => void;
|
|
16
|
+
onSave: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AssignmentLogPanel({ assignments, isDirty, onClear, onSave }: AssignmentLogPanelProps) {
|
|
20
|
+
const entries: AssignmentEntry[] = [];
|
|
21
|
+
|
|
22
|
+
// Global entries
|
|
23
|
+
for (const [event, sound] of Object.entries(assignments.global)) {
|
|
24
|
+
if (sound) entries.push({ scope: 'GLOBAL', event: event as HookEvent, sound });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Agent entries
|
|
28
|
+
for (const [agentName, config] of Object.entries(assignments.agents)) {
|
|
29
|
+
for (const [event, sound] of Object.entries(config.hooks)) {
|
|
30
|
+
if (sound) entries.push({ scope: agentName, event: event as HookEvent, sound });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col overflow-hidden">
|
|
36
|
+
<div className="shrink-0 px-4 py-2 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
|
|
37
|
+
<div className="sf-heading text-xs font-semibold tracking-widest uppercase" style={{ color: 'var(--sf-cyan)' }}>
|
|
38
|
+
ASSIGNMENT LOG
|
|
39
|
+
</div>
|
|
40
|
+
{isDirty && (
|
|
41
|
+
<div className="text-[10px] mt-1" style={{ color: 'var(--sf-gold)' }}>
|
|
42
|
+
● UNSAVED CHANGES
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{/* Assignment table */}
|
|
48
|
+
<div className="flex-1 overflow-y-auto">
|
|
49
|
+
{entries.length === 0 ? (
|
|
50
|
+
<div className="text-xs opacity-30 text-center py-8">NO ASSIGNMENTS</div>
|
|
51
|
+
) : (
|
|
52
|
+
<table className="w-full text-[10px]">
|
|
53
|
+
<thead className="sticky top-0" style={{ backgroundColor: 'var(--sf-panel)' }}>
|
|
54
|
+
<tr>
|
|
55
|
+
<th className="text-left px-3 py-1 opacity-40 uppercase tracking-wider font-normal">Scope</th>
|
|
56
|
+
<th className="text-left px-2 py-1 opacity-40 uppercase tracking-wider font-normal">Event</th>
|
|
57
|
+
<th className="text-left px-2 py-1 opacity-40 uppercase tracking-wider font-normal">Sound</th>
|
|
58
|
+
<th className="px-2 py-1 w-6" />
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody>
|
|
62
|
+
{entries.map((entry, i) => (
|
|
63
|
+
<tr
|
|
64
|
+
key={i}
|
|
65
|
+
className="border-b transition-all hover:bg-white/5"
|
|
66
|
+
style={{ borderColor: 'rgba(0,229,255,0.06)' }}
|
|
67
|
+
>
|
|
68
|
+
<td className="px-3 py-1.5 truncate max-w-[80px]" style={{ color: entry.scope === 'GLOBAL' ? 'var(--sf-gold)' : undefined }}>
|
|
69
|
+
{entry.scope}
|
|
70
|
+
</td>
|
|
71
|
+
<td className="px-2 py-1.5 truncate max-w-[90px] opacity-70">
|
|
72
|
+
{getEventLabel(entry.event)}
|
|
73
|
+
</td>
|
|
74
|
+
<td className="px-2 py-1.5 truncate max-w-[100px] opacity-60">
|
|
75
|
+
{entry.sound.split('/').pop()}
|
|
76
|
+
</td>
|
|
77
|
+
<td className="px-2 py-1.5 text-center">
|
|
78
|
+
<button
|
|
79
|
+
onClick={() => onClear(entry.scope === 'GLOBAL' ? 'global' : entry.scope, entry.event)}
|
|
80
|
+
className="opacity-40 hover:opacity-80 transition-opacity text-[10px]"
|
|
81
|
+
style={{ color: 'var(--sf-alert)' }}
|
|
82
|
+
>
|
|
83
|
+
✕
|
|
84
|
+
</button>
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
))}
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Save button */}
|
|
94
|
+
<div className="shrink-0 p-3 border-t" style={{ borderColor: 'var(--sf-border)' }}>
|
|
95
|
+
<button
|
|
96
|
+
onClick={onSave}
|
|
97
|
+
className={`w-full py-2 text-xs sf-heading font-bold uppercase tracking-widest transition-all ${isDirty ? 'sf-pulse-gold' : ''}`}
|
|
98
|
+
style={{
|
|
99
|
+
border: `1px solid ${isDirty ? 'var(--sf-gold)' : 'var(--sf-border)'}`,
|
|
100
|
+
color: isDirty ? 'var(--sf-gold)' : 'rgba(255,255,255,0.3)',
|
|
101
|
+
backgroundColor: isDirty ? 'rgba(255,192,0,0.06)' : 'transparent',
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
{isDirty ? '\u2B21 ESTABLISH UPLINK' : 'SYNCED'}
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
5
|
+
import { getEventLabel } from '@/lib/utils';
|
|
6
|
+
import { playUISound } from '@/lib/ui-audio';
|
|
7
|
+
import type { HookEvent, SelectMode } from '@/lib/types';
|
|
8
|
+
|
|
9
|
+
interface HookSlotProps {
|
|
10
|
+
event: HookEvent | string;
|
|
11
|
+
scope: string;
|
|
12
|
+
assignedSound?: string;
|
|
13
|
+
onClear: () => void;
|
|
14
|
+
onPreview: (path: string) => void;
|
|
15
|
+
selectMode: SelectMode | null;
|
|
16
|
+
onSelect: () => void; // called when empty slot is clicked to enter select mode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function HookSlot({ event, scope, assignedSound, onClear, onPreview, selectMode, onSelect }: HookSlotProps) {
|
|
20
|
+
const dropId = `${scope}:${event}`;
|
|
21
|
+
const { isOver, setNodeRef } = useDroppable({ id: dropId });
|
|
22
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
23
|
+
|
|
24
|
+
const label = getEventLabel(event as HookEvent);
|
|
25
|
+
const filename = assignedSound ? assignedSound.split('/').pop() ?? assignedSound : null;
|
|
26
|
+
const isSelected = selectMode?.scope === scope && selectMode?.event === event;
|
|
27
|
+
const isSelectModeActive = !!selectMode;
|
|
28
|
+
|
|
29
|
+
const handleContainerClick = () => {
|
|
30
|
+
if (assignedSound) {
|
|
31
|
+
onPreview(assignedSound);
|
|
32
|
+
} else {
|
|
33
|
+
onSelect();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handlePlayClick = (e: React.MouseEvent) => {
|
|
38
|
+
e.stopPropagation();
|
|
39
|
+
if (assignedSound) onPreview(assignedSound);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleClearClick = (e: React.MouseEvent) => {
|
|
43
|
+
e.stopPropagation();
|
|
44
|
+
onClear();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Border logic
|
|
48
|
+
let borderColor: string;
|
|
49
|
+
let borderStyle: 'solid' | 'dashed';
|
|
50
|
+
let bgColor: string;
|
|
51
|
+
|
|
52
|
+
if (isOver) {
|
|
53
|
+
borderColor = 'var(--sf-cyan)';
|
|
54
|
+
borderStyle = 'solid';
|
|
55
|
+
bgColor = 'rgba(0,229,255,0.12)';
|
|
56
|
+
} else if (isSelected) {
|
|
57
|
+
borderColor = 'var(--sf-cyan)';
|
|
58
|
+
borderStyle = 'dashed';
|
|
59
|
+
bgColor = 'rgba(0,229,255,0.08)';
|
|
60
|
+
} else if (assignedSound) {
|
|
61
|
+
borderColor = isHovered ? 'rgba(0,229,255,0.7)' : 'rgba(0,229,255,0.4)';
|
|
62
|
+
borderStyle = 'solid';
|
|
63
|
+
bgColor = isHovered ? 'rgba(0,229,255,0.08)' : 'rgba(0,229,255,0.04)';
|
|
64
|
+
} else if (isSelectModeActive) {
|
|
65
|
+
// Another slot is being selected — mute everything
|
|
66
|
+
borderColor = 'rgba(0,229,255,0.06)';
|
|
67
|
+
borderStyle = 'dashed';
|
|
68
|
+
bgColor = 'transparent';
|
|
69
|
+
} else {
|
|
70
|
+
borderColor = isHovered ? 'rgba(0,229,255,0.25)' : 'rgba(0,229,255,0.12)';
|
|
71
|
+
borderStyle = 'dashed';
|
|
72
|
+
bgColor = isHovered ? 'rgba(0,229,255,0.03)' : 'transparent';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<div
|
|
77
|
+
ref={setNodeRef}
|
|
78
|
+
data-sf-hover
|
|
79
|
+
{...(assignedSound ? { 'data-no-ui-sound': '' } : {})}
|
|
80
|
+
className="flex items-center justify-between px-2 py-1 text-xs transition-all cursor-pointer select-none"
|
|
81
|
+
style={{
|
|
82
|
+
borderWidth: '1px',
|
|
83
|
+
borderStyle,
|
|
84
|
+
borderColor,
|
|
85
|
+
backgroundColor: bgColor,
|
|
86
|
+
boxShadow: isOver ? 'inset 0 0 8px rgba(0,229,255,0.08)' : isSelected ? '0 0 6px rgba(0,229,255,0.15)' : undefined,
|
|
87
|
+
minHeight: '28px',
|
|
88
|
+
opacity: isSelectModeActive && !isSelected ? 0.45 : 1,
|
|
89
|
+
}}
|
|
90
|
+
onMouseEnter={() => { setIsHovered(true); playUISound('hover', 0.15); }}
|
|
91
|
+
onMouseLeave={() => setIsHovered(false)}
|
|
92
|
+
onClick={handleContainerClick}
|
|
93
|
+
>
|
|
94
|
+
<span
|
|
95
|
+
className="text-[10px] uppercase tracking-wider shrink-0 mr-2 transition-opacity"
|
|
96
|
+
style={{ color: 'var(--sf-cyan)', opacity: isOver ? 0.9 : 0.5 }}
|
|
97
|
+
>
|
|
98
|
+
{label}
|
|
99
|
+
</span>
|
|
100
|
+
|
|
101
|
+
{isOver ? (
|
|
102
|
+
<span className="text-[10px] tracking-wider" style={{ color: 'var(--sf-cyan)' }}>
|
|
103
|
+
RELEASE TO ASSIGN ▼
|
|
104
|
+
</span>
|
|
105
|
+
) : isSelected ? (
|
|
106
|
+
<span className="text-[10px] tracking-wider animate-pulse" style={{ color: 'var(--sf-cyan)' }}>
|
|
107
|
+
CLICK A SOUND →
|
|
108
|
+
</span>
|
|
109
|
+
) : assignedSound ? (
|
|
110
|
+
<div className="flex items-center gap-2 overflow-hidden">
|
|
111
|
+
<span
|
|
112
|
+
className="truncate text-[11px] transition-opacity"
|
|
113
|
+
style={{ opacity: isHovered ? 1 : 0.7, color: isHovered ? 'rgba(255,255,255,0.95)' : 'inherit' }}
|
|
114
|
+
title={assignedSound}
|
|
115
|
+
>
|
|
116
|
+
{filename}
|
|
117
|
+
</span>
|
|
118
|
+
<div className="flex items-center gap-1 shrink-0" style={{ opacity: isHovered ? 1 : 0 }}>
|
|
119
|
+
<button
|
|
120
|
+
data-no-ui-sound
|
|
121
|
+
onClick={handlePlayClick}
|
|
122
|
+
className="text-[10px] px-1 transition-opacity"
|
|
123
|
+
style={{ color: 'var(--sf-cyan)' }}
|
|
124
|
+
title="Preview"
|
|
125
|
+
>
|
|
126
|
+
▶
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
data-no-ui-sound
|
|
130
|
+
onClick={handleClearClick}
|
|
131
|
+
className="text-[10px] px-1 transition-opacity"
|
|
132
|
+
style={{ color: 'var(--sf-alert)' }}
|
|
133
|
+
title="Clear"
|
|
134
|
+
>
|
|
135
|
+
✕
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
<span
|
|
141
|
+
className="text-[10px] italic transition-opacity"
|
|
142
|
+
style={{ opacity: isHovered ? 0.5 : 0.2 }}
|
|
143
|
+
>
|
|
144
|
+
{isHovered ? 'click to assign' : 'empty'}
|
|
145
|
+
</span>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { UITheme } from '@/lib/types';
|
|
5
|
+
|
|
6
|
+
interface HudHeaderProps {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
uiTheme: UITheme;
|
|
10
|
+
onUiThemeChange: (theme: UITheme) => void;
|
|
11
|
+
onConfigureUISounds: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const UI_THEMES: { value: UITheme; label: string }[] = [
|
|
15
|
+
{ value: 'sc2', label: 'SC2' },
|
|
16
|
+
{ value: 'wc3', label: 'WC3' },
|
|
17
|
+
{ value: 'ff7', label: 'FF7' },
|
|
18
|
+
{ value: 'ff9', label: 'FF9' },
|
|
19
|
+
{ value: 'off', label: 'OFF' },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export function HudHeader({ enabled, onToggle, uiTheme, onUiThemeChange, onConfigureUISounds }: HudHeaderProps) {
|
|
23
|
+
const [showDropdown, setShowDropdown] = useState(false);
|
|
24
|
+
const activeLabel = UI_THEMES.find((t) => t.value === uiTheme)?.label ?? uiTheme.toUpperCase();
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<header className="shrink-0 flex items-center justify-between px-6 py-3 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
|
|
28
|
+
<div className="flex items-center gap-4">
|
|
29
|
+
<h1 className="sf-logo text-lg font-bold tracking-widest uppercase" style={{ color: 'var(--sf-cyan)' }}>
|
|
30
|
+
AGENTCRAFT
|
|
31
|
+
</h1>
|
|
32
|
+
<span className="text-xs opacity-40" style={{ color: 'var(--sf-cyan)' }}>v0.0.3</span>
|
|
33
|
+
<div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
|
|
34
|
+
<span className="text-xs opacity-60">AUDIO ASSIGNMENT TERMINAL</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div className="flex items-center gap-4">
|
|
38
|
+
{/* UI sound theme dropdown */}
|
|
39
|
+
<div className="flex items-center gap-2">
|
|
40
|
+
<span className="text-[10px] tracking-widest uppercase opacity-40">UI SFX</span>
|
|
41
|
+
|
|
42
|
+
<div className="relative">
|
|
43
|
+
{/* Click-away overlay */}
|
|
44
|
+
{showDropdown && (
|
|
45
|
+
<div className="fixed inset-0 z-10" onClick={() => setShowDropdown(false)} />
|
|
46
|
+
)}
|
|
47
|
+
|
|
48
|
+
{/* Trigger */}
|
|
49
|
+
<button
|
|
50
|
+
data-sf-hover
|
|
51
|
+
data-no-ui-sound
|
|
52
|
+
onClick={() => setShowDropdown(!showDropdown)}
|
|
53
|
+
className="flex items-center gap-1.5 px-3 py-0.5 text-[10px] sf-heading font-medium uppercase tracking-wider transition-all"
|
|
54
|
+
style={{
|
|
55
|
+
position: 'relative',
|
|
56
|
+
zIndex: 11,
|
|
57
|
+
border: `1px solid ${showDropdown ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
|
|
58
|
+
color: uiTheme === 'off' ? 'rgba(255,255,255,0.3)' : 'var(--sf-cyan)',
|
|
59
|
+
backgroundColor: showDropdown ? 'rgba(0,229,255,0.08)' : 'transparent',
|
|
60
|
+
minWidth: '3.5rem',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<span>{activeLabel}</span>
|
|
64
|
+
<span style={{ opacity: 0.5 }}>{showDropdown ? '▴' : '▾'}</span>
|
|
65
|
+
</button>
|
|
66
|
+
|
|
67
|
+
{/* Dropdown */}
|
|
68
|
+
{showDropdown && (
|
|
69
|
+
<div
|
|
70
|
+
className="absolute right-0 top-full mt-1"
|
|
71
|
+
style={{
|
|
72
|
+
zIndex: 20,
|
|
73
|
+
border: '1px solid var(--sf-border)',
|
|
74
|
+
backgroundColor: 'var(--sf-panel)',
|
|
75
|
+
boxShadow: '0 4px 24px rgba(0,0,0,0.6)',
|
|
76
|
+
minWidth: '5rem',
|
|
77
|
+
}}
|
|
78
|
+
>
|
|
79
|
+
{UI_THEMES.map((t, i) => (
|
|
80
|
+
<button
|
|
81
|
+
key={t.value}
|
|
82
|
+
data-sf-hover
|
|
83
|
+
onClick={() => {
|
|
84
|
+
onUiThemeChange(t.value);
|
|
85
|
+
setShowDropdown(false);
|
|
86
|
+
}}
|
|
87
|
+
className="w-full flex items-center gap-1.5 px-3 py-1.5 text-left text-[10px] sf-heading font-medium uppercase tracking-wider transition-all"
|
|
88
|
+
style={{
|
|
89
|
+
color: uiTheme === t.value ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.5)',
|
|
90
|
+
backgroundColor: uiTheme === t.value ? 'rgba(0,229,255,0.08)' : 'transparent',
|
|
91
|
+
borderBottom: i < UI_THEMES.length - 1 ? '1px solid var(--sf-border)' : 'none',
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<span style={{ opacity: uiTheme === t.value ? 1 : 0 }}>▸</span>
|
|
95
|
+
{t.label}
|
|
96
|
+
</button>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Configure gear */}
|
|
103
|
+
<button
|
|
104
|
+
data-sf-hover
|
|
105
|
+
onClick={onConfigureUISounds}
|
|
106
|
+
title="Configure UI sound assignments"
|
|
107
|
+
className="px-2 py-0.5 text-[10px] sf-heading font-medium transition-all"
|
|
108
|
+
style={{
|
|
109
|
+
border: '1px solid var(--sf-border)',
|
|
110
|
+
color: 'rgba(255,255,255,0.35)',
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
⚙
|
|
114
|
+
</button>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div className="h-4 w-px opacity-20" style={{ backgroundColor: 'var(--sf-cyan)' }} />
|
|
118
|
+
|
|
119
|
+
{/* Master enable/disable */}
|
|
120
|
+
<button
|
|
121
|
+
onClick={onToggle}
|
|
122
|
+
className="flex items-center gap-2 px-4 py-1 text-xs sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
123
|
+
style={{
|
|
124
|
+
border: `1px solid ${enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.2)'}`,
|
|
125
|
+
color: enabled ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
|
|
126
|
+
backgroundColor: enabled ? 'rgba(0,229,255,0.08)' : 'transparent',
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: enabled ? 'var(--sf-green)' : 'rgba(255,255,255,0.2)' }} />
|
|
130
|
+
{enabled ? 'ONLINE' : 'OFFLINE'}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</header>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
4
|
+
import { SoundUnit } from './sound-unit';
|
|
5
|
+
import { groupSoundsByCategory, getGroupLabel, getSubTabLabel } from '@/lib/utils';
|
|
6
|
+
import { playUISound } from '@/lib/ui-audio';
|
|
7
|
+
import type { SoundAsset, SoundAssignments, SelectMode } from '@/lib/types';
|
|
8
|
+
|
|
9
|
+
interface SoundBrowserPanelProps {
|
|
10
|
+
sounds: SoundAsset[];
|
|
11
|
+
assignments: SoundAssignments;
|
|
12
|
+
onPreview: (path: string) => void;
|
|
13
|
+
selectMode: SelectMode | null;
|
|
14
|
+
onSelectModeAssign: (path: string) => void;
|
|
15
|
+
onClearSelectMode: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function SoundBrowserPanel({ sounds, assignments, onPreview, selectMode, onSelectModeAssign, onClearSelectMode }: SoundBrowserPanelProps) {
|
|
19
|
+
const [activeGroup, setActiveGroup] = useState<string>('sc2');
|
|
20
|
+
const [activeCategory, setActiveCategory] = useState<string>('sc2/terran');
|
|
21
|
+
const [search, setSearch] = useState('');
|
|
22
|
+
|
|
23
|
+
const handleGroupChange = useCallback((group: string) => {
|
|
24
|
+
setActiveGroup(group);
|
|
25
|
+
playUISound('pageChange', 0.4);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const handleCategoryChange = useCallback((cat: string) => {
|
|
29
|
+
setActiveCategory(cat);
|
|
30
|
+
playUISound('pageChange', 0.35);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const allGroups = useMemo(() => {
|
|
34
|
+
return [...new Set(sounds.map((s) => s.category.split('/')[0]))].sort();
|
|
35
|
+
}, [sounds]);
|
|
36
|
+
|
|
37
|
+
const groupCategories = useMemo(() => {
|
|
38
|
+
return [...new Set(
|
|
39
|
+
sounds.filter((s) => s.category.split('/')[0] === activeGroup).map((s) => s.category)
|
|
40
|
+
)].sort();
|
|
41
|
+
}, [sounds, activeGroup]);
|
|
42
|
+
|
|
43
|
+
// If activeCategory doesn't belong to current group, use first category of the group
|
|
44
|
+
const effectiveCategory = groupCategories.includes(activeCategory)
|
|
45
|
+
? activeCategory
|
|
46
|
+
: (groupCategories[0] ?? '');
|
|
47
|
+
|
|
48
|
+
const assignedPaths = useMemo(() => {
|
|
49
|
+
const paths = new Set<string>();
|
|
50
|
+
Object.values(assignments.global).forEach((p) => p && paths.add(p));
|
|
51
|
+
Object.values(assignments.agents).forEach((a) => {
|
|
52
|
+
Object.values(a.hooks).forEach((p) => p && paths.add(p));
|
|
53
|
+
});
|
|
54
|
+
return paths;
|
|
55
|
+
}, [assignments]);
|
|
56
|
+
|
|
57
|
+
const isSearching = search.trim().length > 0;
|
|
58
|
+
|
|
59
|
+
const filteredSounds = useMemo(() => {
|
|
60
|
+
if (isSearching) {
|
|
61
|
+
// Global search across all sounds
|
|
62
|
+
const q = search.toLowerCase();
|
|
63
|
+
return sounds.filter((s) =>
|
|
64
|
+
s.filename.toLowerCase().includes(q) ||
|
|
65
|
+
s.category.toLowerCase().includes(q) ||
|
|
66
|
+
s.subcategory.toLowerCase().includes(q)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
// Normal tab-filtered view
|
|
70
|
+
return sounds.filter((s) => s.category === effectiveCategory);
|
|
71
|
+
}, [sounds, effectiveCategory, search, isSearching]);
|
|
72
|
+
|
|
73
|
+
const grouped = useMemo(() => groupSoundsByCategory(filteredSounds), [filteredSounds]);
|
|
74
|
+
|
|
75
|
+
const showSubTabs = !isSearching && groupCategories.length > 1;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex flex-col overflow-hidden" style={{ borderLeft: '1px solid var(--sf-border)', borderRight: '1px solid var(--sf-border)' }}>
|
|
79
|
+
{/* Header */}
|
|
80
|
+
<div className="shrink-0 px-4 py-2 border-b" style={{ borderColor: 'var(--sf-border)', backgroundColor: 'var(--sf-panel)' }}>
|
|
81
|
+
<div className="sf-heading text-xs font-semibold tracking-widest uppercase mb-2" style={{ color: 'var(--sf-cyan)' }}>
|
|
82
|
+
SOUND LIBRARY
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{/* Search */}
|
|
86
|
+
<input
|
|
87
|
+
type="text"
|
|
88
|
+
placeholder="SEARCH ALL SOUNDS..."
|
|
89
|
+
value={search}
|
|
90
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
91
|
+
onKeyDown={(e) => e.key === 'Escape' && setSearch('')}
|
|
92
|
+
className="w-full px-3 py-1 text-xs bg-transparent outline-none"
|
|
93
|
+
style={{
|
|
94
|
+
border: `1px solid ${isSearching ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
|
|
95
|
+
color: 'var(--sf-cyan)',
|
|
96
|
+
boxShadow: isSearching ? '0 0 6px rgba(0,229,255,0.15)' : undefined,
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
|
|
100
|
+
{/* Group tabs */}
|
|
101
|
+
<div className="flex gap-1 mt-2 overflow-x-auto" style={{ opacity: isSearching ? 0.3 : 1, pointerEvents: isSearching ? 'none' : 'auto' }}>
|
|
102
|
+
{allGroups.map((group) => (
|
|
103
|
+
<button
|
|
104
|
+
key={group}
|
|
105
|
+
data-sf-hover
|
|
106
|
+
onClick={() => handleGroupChange(group)}
|
|
107
|
+
className="shrink-0 px-3 py-1 text-[10px] sf-heading font-semibold uppercase tracking-wider transition-all"
|
|
108
|
+
style={{
|
|
109
|
+
border: `1px solid ${activeGroup === group ? 'var(--sf-cyan)' : 'var(--sf-border)'}`,
|
|
110
|
+
color: activeGroup === group ? 'var(--sf-cyan)' : 'rgba(255,255,255,0.4)',
|
|
111
|
+
backgroundColor: activeGroup === group ? 'rgba(0,229,255,0.08)' : 'transparent',
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
{getGroupLabel(group)}
|
|
115
|
+
</button>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Sub-tabs (race / platform) */}
|
|
120
|
+
{showSubTabs && (
|
|
121
|
+
<div className="flex gap-1 mt-1 overflow-x-auto">
|
|
122
|
+
{groupCategories.map((cat) => (
|
|
123
|
+
<button
|
|
124
|
+
key={cat}
|
|
125
|
+
data-sf-hover
|
|
126
|
+
onClick={() => handleCategoryChange(cat)}
|
|
127
|
+
className="shrink-0 px-2 py-0.5 text-[9px] sf-heading font-medium uppercase tracking-wider transition-all"
|
|
128
|
+
style={{
|
|
129
|
+
border: `1px solid ${effectiveCategory === cat ? 'rgba(0,229,255,0.6)' : 'rgba(0,229,255,0.15)'}`,
|
|
130
|
+
color: effectiveCategory === cat ? 'rgba(0,229,255,0.8)' : 'rgba(255,255,255,0.3)',
|
|
131
|
+
backgroundColor: effectiveCategory === cat ? 'rgba(0,229,255,0.05)' : 'transparent',
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{getSubTabLabel(cat)}
|
|
135
|
+
</button>
|
|
136
|
+
))}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* Select mode banner */}
|
|
142
|
+
{selectMode && (
|
|
143
|
+
<div
|
|
144
|
+
className="shrink-0 flex items-center justify-between px-4 py-2"
|
|
145
|
+
style={{
|
|
146
|
+
backgroundColor: 'rgba(0,229,255,0.08)',
|
|
147
|
+
borderBottom: '1px solid var(--sf-cyan)',
|
|
148
|
+
boxShadow: '0 0 12px rgba(0,229,255,0.1)',
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<div>
|
|
152
|
+
<span className="text-[10px] sf-heading font-semibold tracking-widest" style={{ color: 'var(--sf-cyan)' }}>
|
|
153
|
+
ASSIGNING → {selectMode.label}
|
|
154
|
+
</span>
|
|
155
|
+
<span className="text-[10px] opacity-40 ml-3">click a sound card to assign</span>
|
|
156
|
+
</div>
|
|
157
|
+
<button
|
|
158
|
+
data-no-ui-sound
|
|
159
|
+
onClick={onClearSelectMode}
|
|
160
|
+
className="text-[10px] px-2 py-0.5 transition-opacity"
|
|
161
|
+
style={{ border: '1px solid rgba(0,229,255,0.3)', color: 'rgba(0,229,255,0.6)' }}
|
|
162
|
+
>
|
|
163
|
+
ESC
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
{/* Sound grid */}
|
|
169
|
+
<div className="flex-1 overflow-y-auto p-3">
|
|
170
|
+
{isSearching && filteredSounds.length > 0 && (
|
|
171
|
+
<div className="text-[9px] uppercase tracking-widest mb-3 opacity-40 px-1">
|
|
172
|
+
{filteredSounds.length} result{filteredSounds.length !== 1 ? 's' : ''}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{Object.entries(grouped).map(([cat, subcats]) => (
|
|
176
|
+
<div key={cat}>
|
|
177
|
+
{isSearching && (
|
|
178
|
+
<div className="text-[9px] uppercase tracking-widest mb-1 mt-3 px-1 first:mt-0" style={{ color: 'var(--sf-cyan)', opacity: 0.6 }}>
|
|
179
|
+
{cat.replace(/\//g, ' › ')}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
{Object.entries(subcats).map(([subcat, catSounds]) => (
|
|
183
|
+
<div key={`${cat}/${subcat}`} className="mb-4">
|
|
184
|
+
{subcat && <div className="text-[10px] uppercase tracking-widest mb-2 opacity-40 px-1">{subcat.replace(/-/g, ' ')}</div>}
|
|
185
|
+
<div className="grid grid-cols-2 gap-2">
|
|
186
|
+
{catSounds.map((sound) => (
|
|
187
|
+
<SoundUnit
|
|
188
|
+
key={sound.id}
|
|
189
|
+
sound={sound}
|
|
190
|
+
isAssigned={assignedPaths.has(sound.path)}
|
|
191
|
+
onPreview={onPreview}
|
|
192
|
+
onSelectAssign={selectMode ? () => onSelectModeAssign(sound.path) : undefined}
|
|
193
|
+
/>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
{filteredSounds.length === 0 && (
|
|
201
|
+
<div className="text-xs opacity-30 text-center py-8">NO SOUNDS FOUND</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|