diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index afad746..7c0fa2d 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -369,6 +369,91 @@ body, box-shadow: none; } +.launch-action-wrap { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: flex-end; +} + +.launch-action-group { + display: inline-flex; + overflow: hidden; + border-radius: 6px; + box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35); +} + +.launch-action-group .launch-btn { + box-shadow: none; +} + +.launch-action-group .launch-btn:hover, +.launch-action-group .launch-btn:active { + transform: none; + box-shadow: none; +} + +.launch-main-btn { + border-radius: 6px 0 0 6px; + border-right-color: rgba(255, 255, 255, 0.18); +} + +.launch-menu-btn { + display: grid; + width: 24px; + place-items: center; + background: var(--accent); + border: 1px solid var(--accent); + border-left: 0; + border-radius: 0 6px 6px 0; + color: #fff; + cursor: pointer; + transition: background 0.2s; +} + +.launch-menu-btn:hover { + background: #5592ff; +} + +.launch-action-dropdown { + position: absolute; + right: 0; + top: calc(100% + 7px); + z-index: 30; + min-width: 104px; + overflow: hidden; + border-radius: 7px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(18, 23, 34, 0.96); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(12px); +} + +.launch-action-dropdown button { + display: flex; + align-items: center; + gap: 7px; + width: 100%; + padding: 9px 12px; + background: transparent; + border: 0; + color: #fca5a5; + font-family: 'NanumGothic', sans-serif; + font-size: 12px; + font-weight: 700; + text-align: left; + cursor: pointer; +} + +.launch-action-dropdown button:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.12); +} + +.launch-action-dropdown button:disabled { + cursor: default; + opacity: 0.55; +} + /* ── Car Video ── */ .car-video { position: absolute; diff --git a/src/renderer/src/pages/Dashboard.tsx b/src/renderer/src/pages/Dashboard.tsx index 694710c..c8777f2 100644 --- a/src/renderer/src/pages/Dashboard.tsx +++ b/src/renderer/src/pages/Dashboard.tsx @@ -83,7 +83,11 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): const [showManual, setShowManual] = useState(false) const [runningApps, setRunningApps] = useState>(new Set()) const [launchingApp, setLaunchingApp] = useState(null) + const [appActionMenuOpen, setAppActionMenuOpen] = useState(false) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deletingApps, setDeletingApps] = useState>({}) const noticeRef = useRef(null) + const appActionMenuRef = useRef(null) const initialAppSelectionDoneRef = useRef(false) const appConfig = apps.find((app) => app.appKey === activeApp) ?? null @@ -99,6 +103,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): && Boolean(appConfig?.launchUrl || appConfig?.execPath || appStatus?.installed) const hasAnyUpdatingApp = Object.values(updatingApps).some(Boolean) const activeAppUpdating = Boolean(updatingApps[activeApp]) + const activeAppDeleting = Boolean(deletingApps[activeApp]) const activeAppLaunching = launchingApp === activeApp const activeUpdateProgress = updateProgressByApp[activeApp] ?? 0 const launcherUpdateAvailable = launcherStatus?.action === 'UPDATE' @@ -110,6 +115,7 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): const activeAppVersion = activeApp ? activeLocalVersion ?? appStatus?.localVersion ?? appStatus?.latestVersion ?? null : null + const activeAppDisplayName = appStatus?.displayName || appConfig?.name || activeApp const refreshSession = useCallback(async (): Promise => { const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, { method: 'POST', @@ -462,6 +468,51 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): } }, [appConfig, completeLocalUpdate, hasAnyUpdatingApp]) + const handleUninstallApp = useCallback(async (): Promise => { + if (!appConfig || isRunning || activeAppUpdating || activeAppDeleting) return + + setDeleteConfirmOpen(false) + setAppActionMenuOpen(false) + setDeletingApps((prev) => ({ ...prev, [activeApp]: true })) + + try { + await window.electron.ipcRenderer.invoke('uninstall-app', { + appName: activeApp, + execPath: appConfig.execPath, + }) + + setInstalledOverrides((prev) => ({ ...prev, [activeApp]: false })) + setLocalVersions((prev) => { + const next = { ...prev } + delete next[activeApp] + return next + }) + setAppStatus((prev) => prev + ? { + ...prev, + installed: false, + localVersion: null, + status: 'NOT_INSTALLED', + action: 'INSTALL', + updateRequired: false, + } + : prev) + } catch (error) { + alert(error instanceof Error ? error.message : '앱 삭제에 실패했습니다.') + } finally { + setDeletingApps((prev) => { + const next = { ...prev } + delete next[activeApp] + return next + }) + } + }, [activeApp, activeAppDeleting, activeAppUpdating, appConfig, isRunning]) + + const openDeleteConfirm = (): void => { + setAppActionMenuOpen(false) + setDeleteConfirmOpen(true) + } + const createHandoffToken = useCallback(async (audienceApp: string): Promise => { const requestHandoff = async (accessToken: string): Promise => { return fetch(`${LAUNCHER_API_BASE_URL}/api/auth/handoff`, { @@ -562,6 +613,21 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): return () => document.removeEventListener('mousedown', handler) }, [noticeOpen]) + useEffect(() => { + setAppActionMenuOpen(false) + }, [activeApp]) + + useEffect(() => { + if (!appActionMenuOpen) return + const handler = (e: MouseEvent): void => { + if (appActionMenuRef.current && !appActionMenuRef.current.contains(e.target as Node)) { + setAppActionMenuOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [appActionMenuOpen]) + return (
@@ -684,12 +750,43 @@ function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): 실행중 ) : ( - +
+
+ + +
+ {appActionMenuOpen && ( +
+ +
+ )} +
) ) : (
)} + {deleteConfirmOpen && ( +
+
+
+

앱 삭제

+ +
+
+

+ {activeAppDisplayName} 앱을 삭제하시겠습니까? +

+

+ 다시 사용하려면 재설치가 필요합니다. +

+
+ +
+
+ )} +