feat(V2-3): Web App MVP — Next.js 15 + @d3ro/api-client
packages/api-client (@d3ro/api-client) 신규: - types.ts: 12개 테이블 Row/Insert/Update 타입 + Database 형식 - client.ts: createD3roSupabaseClient 팩토리 (placeholder fallback) - auth.ts: signInWithOAuth/signOut/getSession/onAuthStateChange - meetings.ts: 회의 + 메모 + 문서 + 전사 CRUD - history.ts: 음성 입력 이력 CRUD - usage.ts: 일일 쿼터 + 구독 조회 - 루트 barrel은 types만 re-export, 함수는 subpath import 전용 apps/web (@d3ro/web) 신규 — Next.js 15 App Router: - transpilePackages: @d3ro/core, ui, i18n, api-client - ThemeProvider (MUI + AppRouterCacheProvider) - I18nProvider (localStorage 어댑터) - AuthProvider (Supabase session Context) - 라우트 9개: - / (auth 상태 기반 리다이렉트) - /login (Google/GitHub OAuth, 미설정 경고) - /auth/callback (code -> session 교환) - /dashboard (요약 카드 + 최근 회의) - /meetings (카드 그리드 리스트) - /meetings/[id] (transcripts/memos/documents) - /record (getUserMedia + MediaRecorder + stt-proxy) - Sidebar, 인증 가드, 9바 웨이브폼, 레벨 미터 packages/ui 확장: - MetalCard가 BoxProps 상속 (sx/onClick 등 전달) - theme.ts에 typoSx(key) 헬퍼 추가 (d3roTypo -> MUI sx 변환) - d3roPalette.tag.blue 추가 (M5 정리 포함) - DS 컴포넌트 9개에 'use client' directive - MetalDial 미사용 import 제거 packages/i18n 확장: - ko.json에 7개 새 키 (nav.meetings/record/logout, login.*) 설계 결정: - Database 제네릭 현재는 기본 타입 (V2-4에서 supabase gen types로 자동화) - api-client barrel은 타입만 노출해서 컴파일 전파 차단 - DS 컴포넌트 client 경계 명시 - env 없어도 Next.js 빌드 성공 (placeholder URL/key) 검증: - web typecheck OK - desktop typecheck OK (회귀 없음) - web next build OK (9 라우트 정적/동적 생성) - desktop build OK (회귀 없음)
This commit is contained in:
parent
9742b2109a
commit
d0c33ca259
190 changed files with 6167 additions and 18 deletions
118
apps/web/src/components/layout/sidebar.tsx
Normal file
118
apps/web/src/components/layout/sidebar.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/layout/sidebar.tsx
|
||||
// 대시보드 좌측 사이드바
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
interface NavItem {
|
||||
key: string
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { t } = useI18n()
|
||||
|
||||
const items: NavItem[] = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
path: '/dashboard',
|
||||
label: t('nav.dashboard') ?? 'Dashboard',
|
||||
icon: <DashboardIcon />
|
||||
},
|
||||
{
|
||||
key: 'meetings',
|
||||
path: '/meetings',
|
||||
label: t('nav.meetings') ?? 'Meetings',
|
||||
icon: <MeetingRoomIcon />
|
||||
},
|
||||
{
|
||||
key: 'record',
|
||||
path: '/record',
|
||||
label: t('nav.record') ?? 'Record',
|
||||
icon: <MicIcon />
|
||||
}
|
||||
]
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
await supabase.auth.signOut()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: 240,
|
||||
minHeight: '100vh',
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.default}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 3, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<PhosphorText variant="heading">
|
||||
D3RO VOICE
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
<List sx={{ flex: 1, py: 2 }}>
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.path || pathname.startsWith(`${item.path}/`)
|
||||
return (
|
||||
<ListItem key={item.key} disablePadding>
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={() => router.push(item.path)}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderLeft: `3px solid ${d3roPalette.accent.amber}`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: active ? d3roPalette.accent.amber : d3roPalette.text.label }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
<List sx={{ borderTop: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => void handleLogout()}>
|
||||
<ListItemIcon sx={{ color: d3roPalette.text.label }}>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={t('nav.logout') ?? 'Logout'}
|
||||
primaryTypographyProps={{ color: d3roPalette.text.secondary }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
58
apps/web/src/components/providers/auth-provider.tsx
Normal file
58
apps/web/src/components/providers/auth-provider.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/providers/auth-provider.tsx
|
||||
// Supabase 세션을 Context로 공유 + 구독으로 실시간 갱신.
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import type { Session, User } from '@supabase/supabase-js'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
interface AuthContextValue {
|
||||
session: Session | null
|
||||
user: User | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const [session, setSession] = useState<Session | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
setSession(data.session)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
setSession(newSession)
|
||||
})
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ session, user: session?.user ?? null, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
31
apps/web/src/components/providers/i18n-provider.tsx
Normal file
31
apps/web/src/components/providers/i18n-provider.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/providers/i18n-provider.tsx
|
||||
// @d3ro/i18n을 웹 환경에 바인딩 (localStorage 어댑터)
|
||||
|
||||
import { I18nProvider as BaseI18nProvider, type I18nStorage, type Locale } from '@d3ro/i18n'
|
||||
|
||||
const LOCALE_KEY = 'd3ro.voice.locale'
|
||||
|
||||
const localStorageI18nStorage: I18nStorage = {
|
||||
load: () => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return window.localStorage.getItem(LOCALE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
save: (locale: Locale) => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.localStorage.setItem(LOCALE_KEY, locale)
|
||||
} catch {
|
||||
// storage 차단 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return <BaseI18nProvider storage={localStorageI18nStorage}>{children}</BaseI18nProvider>
|
||||
}
|
||||
24
apps/web/src/components/providers/theme-provider.tsx
Normal file
24
apps/web/src/components/providers/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/providers/theme-provider.tsx
|
||||
// MUI ThemeProvider + Emotion cache (Next.js App Router용)
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ThemeProvider as MuiThemeProvider, CssBaseline } from '@mui/material'
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'
|
||||
import { getTheme } from '@d3ro/ui/theme'
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
// 웹은 시스템 prefers-color-scheme 대신 사용자 설정 저장소를 나중에 추가.
|
||||
// V2-3 MVP에서는 dark 고정.
|
||||
const theme = useMemo(() => getTheme('dark', true), [])
|
||||
|
||||
return (
|
||||
<AppRouterCacheProvider options={{ key: 'd3ro-mui', enableCssLayer: true }}>
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
{children}
|
||||
</MuiThemeProvider>
|
||||
</AppRouterCacheProvider>
|
||||
)
|
||||
}
|
||||
303
apps/web/src/components/record/mic-recorder.tsx
Normal file
303
apps/web/src/components/record/mic-recorder.tsx
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
'use client'
|
||||
|
||||
// apps/web/src/components/record/mic-recorder.tsx
|
||||
// getUserMedia + MediaRecorder로 오디오 캡처 → Edge Function(stt-proxy)로 전송
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Box, Button, Stack, Alert, CircularProgress } from '@mui/material'
|
||||
import MicIcon from '@mui/icons-material/Mic'
|
||||
import StopIcon from '@mui/icons-material/Stop'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, typoSx } from "@d3ro/ui/theme"
|
||||
import { getSupabaseBrowserClient, isSupabaseConfigured } from '@/lib/supabase-browser'
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'processing' | 'done' | 'error'
|
||||
|
||||
interface SttResponse {
|
||||
transcript: string
|
||||
confidence: number
|
||||
language_code: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export function MicRecorder(): React.ReactElement {
|
||||
const [state, setState] = useState<RecorderState>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [transcript, setTranscript] = useState<string>('')
|
||||
const [level, setLevel] = useState<number>(0)
|
||||
const [elapsed, setElapsed] = useState<number>(0)
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const startAtRef = useRef<number>(0)
|
||||
const elapsedTimerRef = useRef<number | null>(null)
|
||||
const chunksRef = useRef<Blob[]>([])
|
||||
|
||||
const configured = isSupabaseConfigured()
|
||||
|
||||
// cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopStream()
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stopStream = useCallback((): void => {
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||
streamRef.current = null
|
||||
audioContextRef.current?.close().catch(() => {})
|
||||
audioContextRef.current = null
|
||||
analyserRef.current = null
|
||||
}, [])
|
||||
|
||||
const updateLevel = useCallback((): void => {
|
||||
const analyser = analyserRef.current
|
||||
if (!analyser) return
|
||||
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount)
|
||||
analyser.getByteTimeDomainData(buf)
|
||||
let sum = 0
|
||||
for (const v of buf) {
|
||||
const n = (v - 128) / 128
|
||||
sum += n * n
|
||||
}
|
||||
const rms = Math.sqrt(sum / buf.length)
|
||||
setLevel(Math.min(1, rms * 2.5))
|
||||
|
||||
rafRef.current = requestAnimationFrame(updateLevel)
|
||||
}, [])
|
||||
|
||||
const startRecording = useCallback(async (): Promise<void> => {
|
||||
setError(null)
|
||||
setTranscript('')
|
||||
setElapsed(0)
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true
|
||||
}
|
||||
})
|
||||
streamRef.current = stream
|
||||
|
||||
// 오디오 레벨 미터
|
||||
const AudioCtx = window.AudioContext
|
||||
const audioContext = new AudioCtx()
|
||||
audioContextRef.current = audioContext
|
||||
const source = audioContext.createMediaStreamSource(stream)
|
||||
const analyser = audioContext.createAnalyser()
|
||||
analyser.fftSize = 512
|
||||
source.connect(analyser)
|
||||
analyserRef.current = analyser
|
||||
rafRef.current = requestAnimationFrame(updateLevel)
|
||||
|
||||
// MediaRecorder
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm'
|
||||
const mediaRecorder = new MediaRecorder(stream, { mimeType })
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
chunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data)
|
||||
}
|
||||
mediaRecorder.onstop = () => {
|
||||
void handleRecordingComplete()
|
||||
}
|
||||
mediaRecorder.start(250)
|
||||
|
||||
startAtRef.current = Date.now()
|
||||
elapsedTimerRef.current = window.setInterval(() => {
|
||||
setElapsed(Date.now() - startAtRef.current)
|
||||
}, 100)
|
||||
|
||||
setState('recording')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to start recording')
|
||||
setState('error')
|
||||
stopStream()
|
||||
}
|
||||
}, [stopStream, updateLevel])
|
||||
|
||||
const handleRecordingComplete = useCallback(async (): Promise<void> => {
|
||||
setState('processing')
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
|
||||
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' })
|
||||
stopStream()
|
||||
|
||||
if (!configured) {
|
||||
setError('Supabase가 설정되지 않아 전사할 수 없습니다.')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
if (!session) {
|
||||
setError('로그인이 필요합니다.')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('audio', blob, 'recording.webm')
|
||||
formData.append('sample_rate', '16000')
|
||||
formData.append('language_code', 'ko-KR')
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/stt-proxy`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text()
|
||||
throw new Error(`STT failed: ${response.status} ${errText}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SttResponse
|
||||
setTranscript(data.transcript)
|
||||
setState('done')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'STT 처리 실패')
|
||||
setState('error')
|
||||
}
|
||||
}, [configured, stopStream])
|
||||
|
||||
const stopRecording = useCallback((): void => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cancelRecording = useCallback((): void => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.onstop = null
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
if (elapsedTimerRef.current) window.clearInterval(elapsedTimerRef.current)
|
||||
stopStream()
|
||||
setState('idle')
|
||||
setTranscript('')
|
||||
setElapsed(0)
|
||||
setError(null)
|
||||
}, [stopStream])
|
||||
|
||||
const elapsedText = `${String(Math.floor(elapsed / 60000)).padStart(2, '0')}:${String(
|
||||
Math.floor((elapsed / 1000) % 60)
|
||||
).padStart(2, '0')}`
|
||||
|
||||
// 9개 웨이브 바 (Speakly 스타일)
|
||||
const barHeights = Array.from({ length: 9 }, (_, i) => {
|
||||
const dist = Math.abs(i - 4) / 4
|
||||
const base = 1 - dist * 0.5
|
||||
return state === 'recording' ? Math.max(0.15, level * base) : 0.15
|
||||
})
|
||||
|
||||
return (
|
||||
<MetalCard sx={{ p: 4, maxWidth: 600, mx: 'auto' }}>
|
||||
<Stack spacing={3} alignItems="center">
|
||||
<PhosphorText variant="heading">
|
||||
{state === 'recording' ? 'RECORDING' : state === 'processing' ? 'PROCESSING' : 'READY'}
|
||||
</PhosphorText>
|
||||
|
||||
{/* 웨이브 바 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
height: 60,
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{barHeights.map((h, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 8,
|
||||
height: `${h * 100}%`,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
borderRadius: 1,
|
||||
transition: state === 'recording' ? 'height 50ms linear' : 'height 200ms'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{state === 'recording' && (
|
||||
<Box sx={{ ...typoSx("title"), color: d3roPalette.text.primary, fontFamily: 'monospace' }}>
|
||||
{elapsedText}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === 'processing' && <CircularProgress size={32} />}
|
||||
|
||||
{state === 'done' && transcript && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
width: '100%',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
color: d3roPalette.text.primary,
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}
|
||||
>
|
||||
{transcript}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%' }} variant="outlined">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
{state === 'idle' || state === 'done' || state === 'error' ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<MicIcon />}
|
||||
onClick={() => void startRecording()}
|
||||
disabled={!configured}
|
||||
>
|
||||
{state === 'done' ? '새 녹음' : '녹음 시작'}
|
||||
</Button>
|
||||
) : state === 'recording' ? (
|
||||
<>
|
||||
<Button variant="contained" color="error" startIcon={<StopIcon />} onClick={stopRecording}>
|
||||
정지
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<CloseIcon />} onClick={cancelRecording}>
|
||||
취소
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue