feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -1,19 +1,103 @@
// src/renderer/components/shared/EmptyStateCard.tsx
// 공유: 데이터 없음 상태 카드
// High-End Agency Composed Empty State with Vector Glow & Action Support
import { Box } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { Inbox } from 'lucide-react'
import { MetalCard } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
interface EmptyStateCardProps {
export interface EmptyStateCardProps {
/** Main message / title */
message: string
/** Secondary helper text or suggestion */
description?: string
/** Optional icon to render (default: Inbox) */
icon?: React.ReactNode
/** Optional action element (e.g. PhysicalButton) */
action?: React.ReactNode
}
export function EmptyStateCard({ message }: EmptyStateCardProps): React.ReactElement {
export function EmptyStateCard({
message,
description,
icon,
action,
}: EmptyStateCardProps): React.ReactElement {
// If message contains newline, split into title & description
const lines = message.split('\n')
const title = lines[0]
const sub = description || (lines.length > 1 ? lines.slice(1).join(' ') : null)
return (
<MetalCard>
<Box sx={{ py: 6, textAlign: 'center' }}>
<PhosphorText variant="dim">{message}</PhosphorText>
</Box>
</MetalCard>
<div data-testid="empty-state-card">
<MetalCard>
<Box
sx={{
py: 6,
px: 3,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
position: 'relative',
}}
>
{/* Glowing Icon Capsule */}
<Box
sx={{
width: 52,
height: 52,
borderRadius: '50%',
bgcolor: d3roPalette.glass.raised,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: d3roPalette.accent.light,
boxShadow: `0 0 24px ${d3roPalette.accent.dim}`,
mb: 2.5,
'& svg': { width: 24, height: 24 },
}}
>
{icon || <Inbox />}
</Box>
{/* Title */}
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.heading.size,
fontWeight: 600,
color: d3roPalette.text.primary,
mb: sub ? 0.75 : 0,
letterSpacing: '-0.01em',
}}
>
{title}
</Typography>
{/* Description */}
{sub && (
<Typography
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.small.size,
color: d3roPalette.text.secondary,
maxWidth: 420,
lineHeight: 1.6,
mb: action ? 2.5 : 0,
}}
>
{sub}
</Typography>
)}
{/* Action CTA */}
{action && <Box sx={{ mt: 2 }}>{action}</Box>}
</Box>
</MetalCard>
</div>
)
}

View file

@ -1,18 +1,17 @@
// src/renderer/components/shared/HistoryEntryCard.tsx
// 공유: 히스토리 항목 카드 (Dashboard + HistoryPage에서 재사용)
// Phase 10: 태그 표시/추가/삭제 기능 통합
// High-End Agency Dictation History Card with Audio Telemetry & Instant Actions
import { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip } from '@mui/material'
import { Copy, Trash2, Tag, X, ScrollText, ChevronDown } from 'lucide-react'
import { MetalCard, Led } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
import React, { useState, useEffect, useCallback } from 'react'
import { Box, IconButton, Tooltip, Chip, Collapse } from '@mui/material'
import { Copy, Check, Trash2, Tag, X, ScrollText, ChevronDown, Sparkles, Volume2 } from 'lucide-react'
import { MetalCard, Led, TactileBadge } from '@d3ro/ui/components/ds'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import { formatDuration } from '../../utils/formatters'
import { isImeComposingEvent } from '../../utils/keyboard'
import type { HistoryEntry, MemoTag, MeetingSummaryResult } from '@d3ro/core/types'
interface HistoryEntryCardProps {
export interface HistoryEntryCardProps {
entry: HistoryEntry
onCopy?: (text: string) => void
onDelete?: (id: string) => void
@ -20,15 +19,23 @@ interface HistoryEntryCardProps {
onTagClick?: (tag: string) => void
}
export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, onTagClick }: HistoryEntryCardProps): React.ReactElement {
export function HistoryEntryCard({
entry,
onCopy,
onDelete,
showTags = false,
onTagClick,
}: HistoryEntryCardProps): React.ReactElement {
const { t, formatTime } = useI18n()
const displayText = entry.polishedText || entry.originalText
const isPolished = Boolean(entry.polishedText)
const [tags, setTags] = useState<MemoTag[]>([])
const [tagInput, setTagInput] = useState('')
const [showTagInput, setShowTagInput] = useState(false)
const [summaryExpanded, setSummaryExpanded] = useState(false)
const [summary, setSummary] = useState<MeetingSummaryResult | null>(null)
const [summaryLoading, setSummaryLoading] = useState(false)
const [copied, setCopied] = useState(false)
const hasSummary = !!entry.summaryText
const loadTags = useCallback(async () => {
@ -37,150 +44,242 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
if (result.success) setTags(result.data)
}, [entry.id, showTags])
useEffect(() => { loadTags() }, [loadTags])
useEffect(() => {
loadTags()
}, [loadTags])
const handleCopyClick = (e: React.MouseEvent) => {
e.stopPropagation()
onCopy?.(displayText)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
const handleAddTag = useCallback(async () => {
const trimmed = tagInput.trim()
if (!trimmed) return
const result = await window.electronAPI.memo.addTag(entry.id, trimmed)
if (result.success) {
setTags(prev => [...prev, result.data])
setTags((prev) => [...prev, result.data])
setTagInput('')
setShowTagInput(false)
}
}, [entry.id, tagInput])
const handleRemoveTag = useCallback(async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags(prev => prev.filter(t => t.tag !== tag))
}
}, [entry.id])
const handleTagKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') { e.preventDefault(); handleAddTag() }
if (e.key === 'Escape') { setShowTagInput(false); setTagInput('') }
}, [handleAddTag])
const handleToggleSummary = useCallback(async () => {
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
const handleRemoveTag = useCallback(
async (tag: string) => {
const result = await window.electronAPI.memo.removeTag(entry.id, tag)
if (result.success) {
setTags((prev) => prev.filter((t) => t.tag !== tag))
}
setSummaryLoading(false)
}
}, [summaryExpanded, summary, entry.id])
},
[entry.id],
)
const handleGenerateSummary = useCallback(async () => {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.summarize({ historyId: entry.id })
if (result.success) {
setSummary(result.data)
}
setSummaryLoading(false)
}, [entry.id])
const handleTagKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isImeComposingEvent(e)) return
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
if (e.key === 'Escape') {
setShowTagInput(false)
setTagInput('')
}
},
[handleAddTag],
)
const handleExportSummary = useCallback(async () => {
await window.electronAPI.meetingSummary.exportMarkdown({ historyId: entry.id })
}, [entry.id])
const handleToggleSummary = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation()
if (summaryExpanded) {
setSummaryExpanded(false)
return
}
setSummaryExpanded(true)
if (!summary) {
setSummaryLoading(true)
const result = await window.electronAPI.meetingSummary.getSummary({ historyId: entry.id })
if (result.success && result.data) {
setSummary(result.data)
}
setSummaryLoading(false)
}
},
[summaryExpanded, summary, entry.id],
)
return (
<MetalCard>
<MetalCard
sx={{
p: 2.5,
transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease',
'&:hover': {
transform: 'translateY(-1px)',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Led color={entry.status === 'completed' ? 'green' : 'red'} size={6} />
{/* Status Indicator Bead */}
<Box sx={{ pt: 0.5 }}>
<Led
color={entry.status === 'completed' ? 'green' : 'red'}
size={7}
pulse={entry.status === 'processing'}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{/* 타이틀 */}
<Box
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 600,
color: d3roPalette.text.primary,
lineHeight: d3roTypo.compact.line,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.5,
}}
>
{entry.title ?? displayText.slice(0, 60)}
{/* Header Row: Title + Timestamp + Action Icons */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.75 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 0 }}>
<Box
sx={{
fontSize: d3roTypo.compact.size,
fontWeight: 600,
fontFamily: d3roFontSans,
color: d3roPalette.text.primary,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.title ?? displayText.slice(0, 50)}
</Box>
{isPolished && (
<Tooltip title="AI Polished">
<Box sx={{ display: 'flex', alignItems: 'center', color: d3roPalette.accent.light }}>
<Sparkles size={13} />
</Box>
</Tooltip>
)}
</Box>
{/* Quick Action Buttons */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexShrink: 0 }}>
<Tooltip title={copied ? t('common.copied') : t('common.copy')}>
<IconButton
size="small"
onClick={handleCopyClick}
sx={{
p: 0.6,
color: copied ? d3roPalette.tag.green : d3roPalette.text.inactive,
bgcolor: copied ? d3roPalette.tag.greenBg : 'transparent',
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
{onDelete && (
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation()
onDelete(entry.id)
}}
sx={{
p: 0.6,
color: d3roPalette.text.inactive,
'&:hover': { color: d3roPalette.tag.red, bgcolor: d3roPalette.tag.redBg },
}}
>
<Trash2 size={14} />
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{/* 내용 미리보기 */}
{/* Transcript Content Preview */}
<Box
sx={{
fontSize: d3roTypo.small.size,
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.secondary,
lineHeight: 1.5,
lineHeight: 1.55,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
mb: 1.5,
}}
>
{displayText}
</Box>
{/* Metadata Telemetry Badges */}
<Box
sx={{
display: 'flex',
gap: 2,
mt: 1,
flexWrap: 'wrap',
alignItems: 'center',
gap: 1,
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontSize: '11px',
color: d3roPalette.text.dimLabel,
letterSpacing: d3roTypo.label.spacing,
}}
>
<span>{formatTime(entry.createdAt)}</span>
<span>{formatDuration(entry.duration)}</span>
{entry.detectedLanguage && <span>{entry.detectedLanguage.toUpperCase()}</span>}
<span>{entry.mode.toUpperCase()}</span>
<TactileBadge mono tone="mono">
{formatTime(entry.createdAt)}
</TactileBadge>
<TactileBadge mono tone="default">
<Volume2 size={11} style={{ marginRight: 2 }} />
{formatDuration(entry.duration)}
</TactileBadge>
{entry.detectedLanguage && (
<TactileBadge mono tone="accent">
{entry.detectedLanguage.toUpperCase()}
</TactileBadge>
)}
{hasSummary && (
<Chip
icon={<ScrollText size={12} />}
label={t('meetingSummary.title')}
size="small"
<TactileBadge
tone="success"
onClick={handleToggleSummary}
sx={{
height: 18,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.tag.greenBg,
color: d3roPalette.tag.green,
borderRadius: d3roRadius.small,
cursor: 'pointer',
'& .MuiChip-icon': { color: d3roPalette.tag.green },
}}
/>
sx={{ cursor: 'pointer' }}
>
<ScrollText size={11} style={{ marginRight: 2 }} />
{t('meetingSummary.title')}
<ChevronDown
size={12}
style={{
transform: summaryExpanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s ease',
}}
/>
</TactileBadge>
)}
</Box>
{/* 태그 영역 */}
{/* Tags Row */}
{showTags && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1, alignItems: 'center' }}>
{tags.map(tag => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5, mt: 1.25, alignItems: 'center' }}>
{tags.map((tag) => (
<Chip
key={tag.id}
label={`#${tag.tag}`}
size="small"
onClick={() => onTagClick?.(tag.tag)}
onDelete={() => handleRemoveTag(tag.tag)}
deleteIcon={<X size={12} />}
deleteIcon={<X size={11} />}
sx={{
height: 20,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
fontSize: '10.5px',
bgcolor: d3roPalette.tag.purpleBg,
color: d3roPalette.tag.purple,
borderRadius: d3roRadius.small,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 12 },
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.bg.card },
borderRadius: d3roRadius.pill,
'& .MuiChip-deleteIcon': { color: d3roPalette.tag.purple, fontSize: 11 },
'&:hover': { bgcolor: d3roPalette.tag.purple, color: d3roPalette.text.inverse },
}}
/>
))}
@ -190,138 +289,81 @@ export function HistoryEntryCard({ entry, onCopy, onDelete, showTags = false, on
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (!tagInput.trim()) setShowTagInput(false) }}
onBlur={() => {
if (!tagInput.trim()) setShowTagInput(false)
}}
autoFocus
placeholder={t('memo.tagPlaceholder')}
sx={{
border: `1px solid ${d3roPalette.border.subtle}`,
border: `1px solid ${d3roPalette.accent.main}`,
bgcolor: d3roPalette.bg.input,
color: d3roPalette.text.primary,
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
fontSize: '11px',
px: 1,
py: 0.25,
borderRadius: d3roRadius.xs,
borderRadius: d3roRadius.pill,
outline: 'none',
width: 100,
'&:focus': { borderColor: d3roPalette.accent.main },
width: 90,
}}
/>
) : (
<Tooltip title={t('memo.addTag')} arrow>
<IconButton
size="small"
onClick={() => setShowTagInput(true)}
sx={{ p: 0.25, color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.main } }}
>
<Tag size={14} />
</IconButton>
</Tooltip>
<IconButton
size="small"
onClick={() => setShowTagInput(true)}
sx={{
p: 0.4,
color: d3roPalette.text.inactive,
bgcolor: d3roPalette.bg.inset,
borderRadius: '50%',
'&:hover': { color: d3roPalette.accent.light, bgcolor: d3roPalette.glass.raised },
}}
>
<Tag size={11} />
</IconButton>
)}
</Box>
)}
</Box>
<Box sx={{ display: 'flex', gap: 0.5, flexShrink: 0 }}>
{onCopy && (
<Tooltip title={t('common.copy')} arrow>
<IconButton
size="small"
onClick={() => onCopy(displayText)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}
>
<Copy size={16} />
</IconButton>
</Tooltip>
)}
{onDelete && (
<Tooltip title={t('common.delete')} arrow>
<IconButton
size="small"
onClick={() => onDelete(entry.id)}
sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.tag.red } }}
>
<Trash2 size={16} />
</IconButton>
</Tooltip>
)}
{/* Expandable Meeting Summary Box */}
<Collapse in={summaryExpanded}>
<Box
sx={{
mt: 2,
p: 2,
borderRadius: d3roRadius.inner,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.inset,
}}
>
{summaryLoading ? (
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
{t('common.loading')}...
</Box>
) : summary ? (
<Box>
<Box
sx={{
fontFamily: d3roFontSans,
fontSize: '13px',
color: d3roPalette.text.primary,
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
}}
>
{summary.summaryText}
</Box>
</Box>
) : (
<Box sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.text.inactive }}>
{entry.summaryText || t('meetingSummary.empty')}
</Box>
)}
</Box>
</Collapse>
</Box>
</Box>
{/* Phase 12.2: 회의록 요약 확장 뷰 */}
{summaryExpanded && (
<Box sx={{ mt: 2, pt: 2, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
{summaryLoading ? (
<PhosphorText variant="dim">{t('meetingSummary.generating')}</PhosphorText>
) : summary ? (
<Box sx={{ fontSize: d3roTypo.compact.size, color: d3roPalette.text.secondary, lineHeight: 1.6 }}>
{summary.summary && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.summary').toUpperCase()}
</PhosphorText>
<Box sx={{ whiteSpace: 'pre-wrap' }}>{summary.summary}</Box>
</Box>
)}
{summary.decisions.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.decisions').toUpperCase()}
</PhosphorText>
{summary.decisions.map((d, i) => (
<Box key={i} sx={{ pl: 1.5 }}>• {d}</Box>
))}
</Box>
)}
{summary.actionItems.length > 0 && (
<Box sx={{ mb: 1 }}>
<PhosphorText variant="label" sx={{ color: d3roPalette.text.dimLabel, mb: 0.5, display: 'block' }}>
{t('meetingSummary.actionItems').toUpperCase()}
</PhosphorText>
{summary.actionItems.map((a, i) => (
<Box key={i} sx={{ pl: 1.5 }}>☐ {a}</Box>
))}
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Tooltip title={t('meetingSummary.exportMarkdown')} arrow>
<IconButton size="small" onClick={handleExportSummary} sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.main } }}>
<ScrollText size={14} />
</IconButton>
</Tooltip>
</Box>
</Box>
) : (
<Box sx={{ textAlign: 'center' }}>
<PhosphorText variant="dim" sx={{ mb: 1 }}>{t('meetingSummary.noSummary')}</PhosphorText>
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && (
<Chip
label={t('meetingSummary.generate')}
size="small"
onClick={handleGenerateSummary}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.micro.size,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.chassis,
cursor: 'pointer',
'&:hover': { opacity: 0.85 },
}}
/>
)}
</Box>
)}
</Box>
)}
{/* 요약 토글 버튼 (caption/file-transcription 모드만) */}
{(entry.mode === 'caption' || entry.mode === 'file-transcription') && !summaryExpanded && !hasSummary && (
<Box
sx={{ mt: 1, textAlign: 'center', cursor: 'pointer', color: d3roPalette.text.muted, '&:hover': { color: d3roPalette.accent.main } }}
onClick={handleToggleSummary}
>
<ChevronDown size={16} />
</Box>
)}
</MetalCard>
)
}

View file

@ -1,17 +1,24 @@
// src/renderer/components/shared/SearchInput.tsx
// 공유: 모노 폰트 검색 입력 필드
// Precision Glass Search Bar with Tactile Clear & Keycap Indicator
import { TextField, InputAdornment } from '@mui/material'
import { Search } from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
import React from 'react'
import { TextField, InputAdornment, IconButton, Box } from '@mui/material'
import { Search, X } from 'lucide-react'
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
interface SearchInputProps {
export interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder: string
shortcut?: string
}
export function SearchInput({ value, onChange, placeholder }: SearchInputProps): React.ReactElement {
export function SearchInput({
value,
onChange,
placeholder,
shortcut = '/',
}: SearchInputProps): React.ReactElement {
return (
<TextField
placeholder={placeholder}
@ -19,20 +26,76 @@ export function SearchInput({ value, onChange, placeholder }: SearchInputProps):
onChange={(e) => onChange(e.target.value)}
fullWidth
sx={{
mb: 3,
mb: 2.5,
'& .MuiOutlinedInput-root': {
bgcolor: d3roPalette.glass.raised,
backdropFilter: `blur(${d3roPalette.glass.blur})`,
borderRadius: d3roRadius.inner,
border: `1px solid ${d3roPalette.glass.hairline}`,
boxShadow: d3roShadow.inset,
transition: 'all 0.2s ease',
'& fieldset': { border: 'none' },
'&:hover': {
borderColor: d3roPalette.glass.hairlineStrong,
bgcolor: d3roPalette.bg.cardHover,
},
'&.Mui-focused': {
border: `1px solid ${d3roPalette.accent.main}`,
boxShadow: `${d3roShadow.inset}, 0 0 0 3px ${d3roPalette.accent.dim}`,
},
},
'& .MuiInputBase-input': {
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
letterSpacing: d3roTypo.small.spacing,
fontFamily: d3roFontSans,
fontSize: d3roTypo.compact.size,
color: d3roPalette.text.primary,
py: 1.25,
'&::placeholder': {
color: d3roPalette.text.disabled,
opacity: 1,
},
},
}}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search size={18} style={{ color: d3roPalette.text.inactive }} />
<InputAdornment position="start" sx={{ pl: 0.5 }}>
<Search size={17} style={{ color: d3roPalette.text.secondary }} />
</InputAdornment>
),
endAdornment: value ? (
<InputAdornment position="end">
<IconButton
size="small"
onClick={() => onChange('')}
sx={{
color: d3roPalette.text.inactive,
p: 0.5,
'&:hover': { color: d3roPalette.text.primary },
}}
>
<X size={15} />
</IconButton>
</InputAdornment>
) : shortcut ? (
<InputAdornment position="end" sx={{ pr: 0.5 }}>
<Box
sx={{
px: 0.85,
py: 0.2,
borderRadius: d3roRadius.xs,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.text.dimLabel,
lineHeight: 1,
userSelect: 'none',
}}
>
{shortcut}
</Box>
</InputAdornment>
) : undefined,
},
}}
/>