init studio launcher frontend
This commit is contained in:
@@ -0,0 +1,950 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, protocol, net, session as electronSession, dialog } from 'electron'
|
||||
import { join, dirname, resolve } from 'path'
|
||||
import { chmodSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
||||
import { get as httpGet } from 'http'
|
||||
import { get as httpsGet } from 'https'
|
||||
import { spawn, exec, ChildProcess } from 'child_process'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{ scheme: 'appdocs', privileges: { secure: true, standard: true, supportFetchAPI: true, corsEnabled: true } }
|
||||
])
|
||||
|
||||
let splash: BrowserWindow | null = null
|
||||
|
||||
type CommandResult = {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
type TailscaleStatusResult = {
|
||||
connected: boolean
|
||||
ip: string
|
||||
backendState: string
|
||||
online?: boolean
|
||||
health?: string[]
|
||||
loginName?: string
|
||||
userName?: string
|
||||
peers?: TailscalePeerStatus[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
type TailscalePeerStatus = {
|
||||
id: string
|
||||
hostName: string
|
||||
dnsName?: string
|
||||
os?: string
|
||||
userName?: string
|
||||
loginName?: string
|
||||
ip: string
|
||||
ips: string[]
|
||||
online: boolean
|
||||
active: boolean
|
||||
lastSeen?: string
|
||||
relay?: string
|
||||
currentAddress?: string
|
||||
}
|
||||
|
||||
type SshKeyResult = {
|
||||
publicKey: string
|
||||
publicKeyPath: string
|
||||
privateKeyPath: string
|
||||
}
|
||||
|
||||
function getLauncherDir(): string {
|
||||
if (process.env.APPIMAGE) return dirname(process.env.APPIMAGE)
|
||||
if (is.dev) return process.cwd()
|
||||
return dirname(process.execPath)
|
||||
}
|
||||
|
||||
function resolveExecPath(execPath?: string): string | undefined {
|
||||
if (!execPath) return undefined
|
||||
return execPath.startsWith('./')
|
||||
? join(getLauncherDir(), execPath.slice(2))
|
||||
: execPath
|
||||
}
|
||||
|
||||
function getWorkspaceRoot(): string {
|
||||
return is.dev ? resolve(process.cwd(), '..') : getLauncherDir()
|
||||
}
|
||||
|
||||
function getLauncherSshKeyPath(): string {
|
||||
return join(app.getPath('home'), '.autonomy-studio', 'ssh', 'id_ed25519')
|
||||
}
|
||||
|
||||
function resolveDevAppPath(devAppPath?: string): string | undefined {
|
||||
if (!devAppPath) return undefined
|
||||
return devAppPath.startsWith('./') || devAppPath.startsWith('../')
|
||||
? resolve(getWorkspaceRoot(), devAppPath)
|
||||
: devAppPath
|
||||
}
|
||||
|
||||
function getDefaultDevAppPath(appName: string): string | undefined {
|
||||
const devPathByApp: Record<string, string> = {
|
||||
'ADS IDE': './apps/ads-ide/ads-ide-fe',
|
||||
LMS: './apps/lms/lms-fe',
|
||||
}
|
||||
return resolveDevAppPath(devPathByApp[appName])
|
||||
}
|
||||
|
||||
function isRunnableDevAppPath(devAppPath?: string): boolean {
|
||||
return Boolean(devAppPath && existsSync(join(devAppPath, 'package.json')))
|
||||
}
|
||||
|
||||
function writeDevHandoffFile(appName: string, appUrl: string, handoffToken: string, audienceApp: string): string {
|
||||
const safeAppName = appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()
|
||||
const filePath = join(app.getPath('temp'), `autonomy-studio-${safeAppName}-handoff-${process.pid}.json`)
|
||||
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
appUrl,
|
||||
handoffToken,
|
||||
audienceApp,
|
||||
createdAt: new Date().toISOString()
|
||||
}))
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
function getVersionFilePath(targetPath: string): string {
|
||||
return `${targetPath}.version`
|
||||
}
|
||||
|
||||
function readInstalledVersion(targetPath?: string): string | null {
|
||||
if (!targetPath) return null
|
||||
const versionFilePath = getVersionFilePath(targetPath)
|
||||
if (!existsSync(versionFilePath)) return null
|
||||
|
||||
try {
|
||||
return readFileSync(versionFilePath, 'utf8').trim() || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function removeMatchingFiles(dirPath: string, shouldRemove: (fileName: string) => boolean): number {
|
||||
if (!existsSync(dirPath)) return 0
|
||||
|
||||
let removed = 0
|
||||
for (const entry of readdirSync(dirPath)) {
|
||||
const fullPath = join(dirPath, entry)
|
||||
try {
|
||||
if (statSync(fullPath).isFile() && shouldRemove(entry)) {
|
||||
rmSync(fullPath, { force: true })
|
||||
removed += 1
|
||||
}
|
||||
} catch {
|
||||
// Ignore files that disappear while cleanup is running.
|
||||
}
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
function countMatchingFiles(dirPath: string, shouldCount: (fileName: string) => boolean): number {
|
||||
if (!existsSync(dirPath)) return 0
|
||||
|
||||
let count = 0
|
||||
for (const entry of readdirSync(dirPath)) {
|
||||
const fullPath = join(dirPath, entry)
|
||||
try {
|
||||
if (statSync(fullPath).isFile() && shouldCount(entry)) {
|
||||
count += 1
|
||||
}
|
||||
} catch {
|
||||
// Ignore files that disappear while cache state is being checked.
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
async function getLauncherCacheStatus(): Promise<{ available: boolean; files: number; rendererCacheBytes: number }> {
|
||||
const tempFiles = countMatchingFiles(app.getPath('temp'), (fileName) =>
|
||||
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
|
||||
)
|
||||
const downloadFiles = countMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
|
||||
const rendererCacheBytes = await electronSession.defaultSession.getCacheSize()
|
||||
|
||||
return {
|
||||
available: tempFiles + downloadFiles > 0 || rendererCacheBytes > 0,
|
||||
files: tempFiles + downloadFiles,
|
||||
rendererCacheBytes,
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLauncherCache(): Promise<{ ok: true; removedFiles: number }> {
|
||||
const removedTempFiles = removeMatchingFiles(app.getPath('temp'), (fileName) =>
|
||||
/^autonomy-studio-launcher-.+\.deb(?:\.download)?$/.test(fileName)
|
||||
)
|
||||
const removedDownloadFiles = removeMatchingFiles(getLauncherDir(), (fileName) => fileName.endsWith('.download'))
|
||||
|
||||
await electronSession.defaultSession.clearCache()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
removedFiles: removedTempFiles + removedDownloadFiles,
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
onProgress: (progress: number) => void,
|
||||
): Promise<void> {
|
||||
const tmpPath = `${targetPath}.download`
|
||||
const client = url.startsWith('https:') ? httpsGet : httpGet
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
mkdirSync(dirname(targetPath), { recursive: true })
|
||||
rmSync(tmpPath, { force: true })
|
||||
|
||||
const request = client(url, (response) => {
|
||||
const statusCode = response.statusCode ?? 0
|
||||
const redirectUrl = response.headers.location
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400 && redirectUrl) {
|
||||
response.resume()
|
||||
const nextUrl = new URL(redirectUrl, url).toString()
|
||||
downloadFile(nextUrl, targetPath, onProgress).then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
response.resume()
|
||||
reject(new Error(`Download failed with status ${statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const total = Number(response.headers['content-length'] ?? 0)
|
||||
let received = 0
|
||||
const file = createWriteStream(tmpPath)
|
||||
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
received += chunk.length
|
||||
if (total > 0) {
|
||||
onProgress(Math.min(99, Math.round((received / total) * 100)))
|
||||
}
|
||||
})
|
||||
|
||||
response.pipe(file)
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
renameSync(tmpPath, targetPath)
|
||||
chmodSync(targetPath, 0o755)
|
||||
onProgress(100)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
file.on('error', (error) => {
|
||||
rmSync(tmpPath, { force: true })
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
rmSync(tmpPath, { force: true })
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function installDebPackage(debPath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('pkexec', ['dpkg', '-i', debPath], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(`Launcher update failed with exit code ${code ?? 'unknown'}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], timeoutMs = 30000, extraEnv?: NodeJS.ProcessEnv): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
reject(new Error(`${command} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += String(chunk)
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr })
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || stdout.trim() || `${command} exited with code ${code ?? 'unknown'}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function commandErrorMessage(error: unknown, fallback: string): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (/request dismissed|dismissed|cancelled|canceled/i.test(message)) return '인증이 취소되었습니다'
|
||||
if (/not authorized|authentication failed/i.test(message)) return '관리자 인증에 실패했습니다'
|
||||
if (/No such file or directory|ENOENT/i.test(message)) return '명령을 찾을 수 없습니다'
|
||||
const sanitized = message
|
||||
.replace(/(?:tskey|hskey)-auth-[A-Za-z0-9_-]+/g, '[auth-key]')
|
||||
.trim()
|
||||
return sanitized ? `${fallback}: ${sanitized}` : fallback
|
||||
}
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function isTailscalePermissionError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return /set --operator|not require root|permission denied|access denied|must be root|requires root/i.test(message)
|
||||
}
|
||||
|
||||
function tailscaleCommandEnv(loginServer: string): NodeJS.ProcessEnv | undefined {
|
||||
return /^http:\/\//i.test(loginServer.trim()) ? { TS_DEBUG_ALLOW_HTTP: '1' } : undefined
|
||||
}
|
||||
|
||||
function hasTailscaleCoordinationIssue(health?: string[]): boolean {
|
||||
return Boolean(
|
||||
health?.some((message) =>
|
||||
/coordination server|synchronize the state|control server|login server|headscale/i.test(message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async function runCommandIfPossible(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = 10000,
|
||||
extraEnv?: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await runCommand(command, args, timeoutMs, extraEnv)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLauncherSshKey(): Promise<SshKeyResult> {
|
||||
const privateKeyPath = getLauncherSshKeyPath()
|
||||
const publicKeyPath = `${privateKeyPath}.pub`
|
||||
mkdirSync(dirname(privateKeyPath), { recursive: true, mode: 0o700 })
|
||||
|
||||
if (!existsSync(privateKeyPath) || !existsSync(publicKeyPath)) {
|
||||
rmSync(privateKeyPath, { force: true })
|
||||
rmSync(publicKeyPath, { force: true })
|
||||
await runCommand('ssh-keygen', [
|
||||
'-t',
|
||||
'ed25519',
|
||||
'-N',
|
||||
'',
|
||||
'-C',
|
||||
'autonomy-studio-launcher',
|
||||
'-f',
|
||||
privateKeyPath,
|
||||
])
|
||||
}
|
||||
|
||||
chmodSync(dirname(privateKeyPath), 0o700)
|
||||
chmodSync(privateKeyPath, 0o600)
|
||||
chmodSync(publicKeyPath, 0o644)
|
||||
|
||||
return {
|
||||
publicKey: readFileSync(publicKeyPath, 'utf8').trim(),
|
||||
publicKeyPath,
|
||||
privateKeyPath,
|
||||
}
|
||||
}
|
||||
|
||||
async function resetTailscaleSession(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||
await runCommandIfPossible('tailscale', ['logout'], 10000, commandEnv)
|
||||
await runCommandIfPossible('tailscale', ['down'], 10000, commandEnv)
|
||||
}
|
||||
|
||||
async function resetTailscaleSessionWithPkexec(commandEnv?: NodeJS.ProcessEnv): Promise<void> {
|
||||
const loggedOut = await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'logout'], 30000, commandEnv)
|
||||
if (!loggedOut) {
|
||||
await runCommandIfPossible('pkexec', ['/usr/bin/tailscale', 'down'], 30000, commandEnv)
|
||||
}
|
||||
}
|
||||
|
||||
async function readTailscaleStatus(): Promise<TailscaleStatusResult> {
|
||||
const attempts: Array<[string, string[]]> = [
|
||||
['tailscale', ['status', '--json']],
|
||||
['tailscale', ['status']],
|
||||
]
|
||||
|
||||
let lastError = ''
|
||||
for (const [command, args] of attempts) {
|
||||
try {
|
||||
const { stdout } = await runCommand(command, args)
|
||||
if (args.includes('--json')) {
|
||||
const status = JSON.parse(stdout) as {
|
||||
BackendState?: string
|
||||
Self?: { TailscaleIPs?: string[]; UserID?: number; LoginName?: string; Online?: boolean }
|
||||
Peer?: Record<string, {
|
||||
ID?: string
|
||||
HostName?: string
|
||||
DNSName?: string
|
||||
OS?: string
|
||||
UserID?: number
|
||||
TailscaleIPs?: string[]
|
||||
Online?: boolean
|
||||
Active?: boolean
|
||||
LastSeen?: string
|
||||
Relay?: string
|
||||
CurAddr?: string
|
||||
}>
|
||||
Health?: string[]
|
||||
User?: Record<string, { LoginName?: string; ProfilePicURL?: string; DisplayName?: string }>
|
||||
}
|
||||
const selfUser = status.Self?.UserID != null ? status.User?.[String(status.Self.UserID)] : undefined
|
||||
const health = Array.isArray(status.Health) ? status.Health : []
|
||||
const online = status.Self?.Online === true
|
||||
const connected = status.BackendState === 'Running' && online && !hasTailscaleCoordinationIssue(health)
|
||||
const peers = Object.entries(status.Peer ?? {}).map(([key, peer]) => {
|
||||
const user = peer.UserID != null ? status.User?.[String(peer.UserID)] : undefined
|
||||
const ips = Array.isArray(peer.TailscaleIPs) ? peer.TailscaleIPs : []
|
||||
return {
|
||||
id: peer.ID ?? key,
|
||||
hostName: peer.HostName ?? peer.DNSName ?? key,
|
||||
dnsName: peer.DNSName,
|
||||
os: peer.OS,
|
||||
userName: user?.DisplayName,
|
||||
loginName: user?.LoginName,
|
||||
ip: ips[0] ?? '',
|
||||
ips,
|
||||
online: peer.Online === true,
|
||||
active: peer.Active === true,
|
||||
lastSeen: peer.LastSeen,
|
||||
relay: peer.Relay,
|
||||
currentAddress: peer.CurAddr,
|
||||
}
|
||||
})
|
||||
return {
|
||||
connected,
|
||||
ip: status.Self?.TailscaleIPs?.[0] ?? '',
|
||||
backendState: status.BackendState ?? 'Unknown',
|
||||
online,
|
||||
health,
|
||||
loginName: status.Self?.LoginName ?? selfUser?.LoginName ?? '',
|
||||
userName: selfUser?.DisplayName ?? '',
|
||||
peers,
|
||||
error: connected ? undefined : health[0],
|
||||
}
|
||||
}
|
||||
|
||||
const output = stdout.trim()
|
||||
const connected = Boolean(output) && !/logged out|not logged in/i.test(output)
|
||||
return {
|
||||
connected,
|
||||
ip: output.match(/\b100\.\d+\.\d+\.\d+\b/)?.[0] ?? '',
|
||||
backendState: connected ? 'Running' : 'Stopped',
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
connected: false,
|
||||
ip: '',
|
||||
backendState: 'Unavailable',
|
||||
error: lastError || 'tailscale status failed',
|
||||
}
|
||||
}
|
||||
|
||||
async function loginTailscale(
|
||||
authKey: string,
|
||||
loginServer: string,
|
||||
hostname?: string,
|
||||
forceReset = false,
|
||||
): Promise<CommandResult> {
|
||||
const tailscaleArgs = ['up']
|
||||
const trimmedAuthKey = authKey.trim()
|
||||
const trimmedLoginServer = loginServer.trim()
|
||||
const trimmedHostname = hostname?.trim() ?? ''
|
||||
const commandEnv = tailscaleCommandEnv(trimmedLoginServer)
|
||||
|
||||
if (forceReset) {
|
||||
await resetTailscaleSession(commandEnv)
|
||||
tailscaleArgs.push('--reset')
|
||||
}
|
||||
if (trimmedLoginServer) {
|
||||
tailscaleArgs.push('--login-server', trimmedLoginServer)
|
||||
}
|
||||
if (trimmedAuthKey) {
|
||||
tailscaleArgs.push('--auth-key', trimmedAuthKey)
|
||||
}
|
||||
if (trimmedHostname) {
|
||||
tailscaleArgs.push('--hostname', trimmedHostname)
|
||||
}
|
||||
|
||||
try {
|
||||
await runCommand('tailscale', tailscaleArgs, 30000, commandEnv)
|
||||
await waitForTailscaleConnected()
|
||||
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||
} catch (error) {
|
||||
if (isTailscalePermissionError(error)) {
|
||||
try {
|
||||
if (forceReset) {
|
||||
await resetTailscaleSessionWithPkexec(commandEnv)
|
||||
}
|
||||
await runCommand('pkexec', ['/usr/bin/tailscale', ...tailscaleArgs], 60000, commandEnv)
|
||||
await waitForTailscaleConnected()
|
||||
return { ok: true, message: 'Tailscale 연결 완료' }
|
||||
} catch (pkexecError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(pkexecError, commandErrorMessage(error, 'Tailscale 제어 권한이 없습니다.')),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(error, 'Tailscale 연결 실패'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTailscaleConnected(): Promise<void> {
|
||||
let lastStatus: TailscaleStatusResult | null = null
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await delay(500)
|
||||
const status = await readTailscaleStatus()
|
||||
lastStatus = status
|
||||
if (status.connected) return
|
||||
}
|
||||
throw new Error(lastStatus?.error || `Tailscale state is ${lastStatus?.backendState ?? 'not connected'}`)
|
||||
}
|
||||
|
||||
async function logoutTailscale(): Promise<CommandResult> {
|
||||
try {
|
||||
await runCommand('tailscale', ['logout'])
|
||||
return { ok: true, message: 'Tailscale 로그아웃 완료' }
|
||||
} catch (error) {
|
||||
if (isTailscalePermissionError(error)) {
|
||||
try {
|
||||
await runCommand('tailscale', ['down'])
|
||||
return { ok: true, message: 'Tailscale 연결 해제 완료' }
|
||||
} catch (downError) {
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(downError, commandErrorMessage(error, 'Tailscale 로그아웃 권한이 없습니다.')),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: commandErrorMessage(error, 'Tailscale 로그아웃 실패'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSplashWindow(): void {
|
||||
splash = new BrowserWindow({
|
||||
width: 580,
|
||||
height: 360,
|
||||
show: true,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
center: true,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#07111f'
|
||||
})
|
||||
const splashPath = is.dev
|
||||
? join(process.cwd(), 'resources/splash.html')
|
||||
: join(__dirname, '../renderer/splash.html')
|
||||
splash.loadFile(splashPath, { query: { version: app.getVersion() } })
|
||||
}
|
||||
|
||||
function attachDevToolsShortcut(window: BrowserWindow): void {
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
const key = input.key.toLowerCase()
|
||||
if (input.key === 'F12' || (input.control && input.shift && key === 'i')) {
|
||||
event.preventDefault()
|
||||
window.webContents.toggleDevTools()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
const appWindows = new Map<string, BrowserWindow>()
|
||||
const appProcesses = new Map<string, ChildProcess>()
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1366,
|
||||
height: 860,
|
||||
icon: join(process.cwd(), 'resources/icon.png'),
|
||||
show: false,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
attachDevToolsShortcut(mainWindow)
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
setTimeout(() => {
|
||||
splash?.close()
|
||||
splash = null
|
||||
mainWindow.show()
|
||||
}, 1500)
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
ipcMain.on('window-minimize', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
})
|
||||
ipcMain.on('window-maximize', (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
win.isMaximized() ? win.unmaximize() : win.maximize()
|
||||
})
|
||||
ipcMain.on('window-close', (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.close()
|
||||
})
|
||||
|
||||
ipcMain.handle('get-app-installation', (_, payload: {
|
||||
appName?: string
|
||||
execPath?: string
|
||||
devAppPath?: string
|
||||
}) => {
|
||||
const devAppPath = resolveDevAppPath(payload.devAppPath) ?? (payload.appName ? getDefaultDevAppPath(payload.appName) : undefined)
|
||||
if (is.dev && devAppPath) {
|
||||
const packagePath = join(devAppPath, 'package.json')
|
||||
let version: string | null = null
|
||||
try {
|
||||
version = JSON.parse(readFileSync(packagePath, 'utf8')).version ?? null
|
||||
} catch {
|
||||
version = null
|
||||
}
|
||||
|
||||
return {
|
||||
installed: existsSync(packagePath),
|
||||
version,
|
||||
path: devAppPath,
|
||||
}
|
||||
}
|
||||
|
||||
const targetPath = resolveExecPath(payload.execPath)
|
||||
|
||||
return {
|
||||
installed: targetPath ? existsSync(targetPath) : false,
|
||||
version: readInstalledVersion(targetPath),
|
||||
path: targetPath ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('download-app', async (event, payload: {
|
||||
appName: string
|
||||
downloadUrl: string
|
||||
execPath?: string
|
||||
latestVersion?: string
|
||||
}) => {
|
||||
const targetPath = resolveExecPath(payload.execPath)
|
||||
if (!targetPath) throw new Error('설치 경로가 설정되지 않았습니다.')
|
||||
|
||||
await downloadFile(payload.downloadUrl, targetPath, (progress) => {
|
||||
event.sender.send('app-download-progress', {
|
||||
appName: payload.appName,
|
||||
progress,
|
||||
})
|
||||
})
|
||||
|
||||
if (payload.latestVersion) {
|
||||
writeFileSync(getVersionFilePath(targetPath), `${payload.latestVersion}\n`, 'utf8')
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: payload.latestVersion ?? readInstalledVersion(targetPath),
|
||||
path: targetPath,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('install-launcher-update', async (event, payload: {
|
||||
downloadUrl: string
|
||||
latestVersion?: string
|
||||
}) => {
|
||||
const version = payload.latestVersion?.replace(/[^0-9A-Za-z._-]/g, '') || String(Date.now())
|
||||
const debPath = join(app.getPath('temp'), `autonomy-studio-launcher-${version}.deb`)
|
||||
|
||||
await downloadFile(payload.downloadUrl, debPath, (progress) => {
|
||||
event.sender.send('app-download-progress', {
|
||||
appName: 'launcher',
|
||||
progress,
|
||||
})
|
||||
})
|
||||
|
||||
await installDebPackage(debPath)
|
||||
|
||||
if (!is.dev) {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version: payload.latestVersion ?? null,
|
||||
path: debPath,
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('get-launcher-cache-status', () => getLauncherCacheStatus())
|
||||
ipcMain.handle('clear-launcher-cache', () => clearLauncherCache())
|
||||
ipcMain.handle('launcher-tailscale-status', () => readTailscaleStatus())
|
||||
ipcMain.handle('launcher-ssh-key-ensure', () => ensureLauncherSshKey())
|
||||
ipcMain.handle(
|
||||
'launcher-tailscale-login',
|
||||
(_event, authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||
loginTailscale(authKey, loginServer, hostname, Boolean(forceReset)),
|
||||
)
|
||||
ipcMain.handle('launcher-tailscale-logout', () => logoutTailscale())
|
||||
|
||||
const onAppClosed = (appName: string): void => {
|
||||
mainWindow.webContents.send('app-window-closed', appName)
|
||||
if (appWindows.size === 0 && appProcesses.size === 0) {
|
||||
mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('launch-app', (_, payload: {
|
||||
appName: string
|
||||
url: string
|
||||
width?: number
|
||||
height?: number
|
||||
devAppPath?: string
|
||||
execPath?: string
|
||||
handoffCode?: string
|
||||
audienceApp?: string
|
||||
}) => {
|
||||
const { appName, url, width = 1280, height = 800, devAppPath, execPath, handoffCode, audienceApp = appName } = payload
|
||||
const authProtocolUrl = handoffCode && audienceApp === 'ADS IDE' ? `ads-ide://auth?handoffToken=${encodeURIComponent(handoffCode)}` : undefined
|
||||
const authLaunchArgs = handoffCode
|
||||
? [
|
||||
`--handoff-token=${encodeURIComponent(handoffCode)}`,
|
||||
`--audience-app=${encodeURIComponent(audienceApp)}`
|
||||
]
|
||||
: []
|
||||
|
||||
// 이미 실행 중이면 포커스
|
||||
const existingWin = appWindows.get(appName)
|
||||
if (existingWin && !existingWin.isDestroyed()) {
|
||||
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
|
||||
existingWin.focus()
|
||||
return
|
||||
}
|
||||
const existingProc = appProcesses.get(appName)
|
||||
if (existingProc) {
|
||||
if (is.dev && handoffCode && audienceApp !== 'ADS IDE') {
|
||||
existingProc.kill()
|
||||
appProcesses.delete(appName)
|
||||
} else {
|
||||
if (authProtocolUrl) shell.openExternal(authProtocolUrl)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.minimize()
|
||||
|
||||
// ── 별도 Electron 프로세스로 실행 ──
|
||||
const resolvedExecPath = resolveExecPath(execPath)
|
||||
const resolvedDevAppPath = resolveDevAppPath(devAppPath) ?? getDefaultDevAppPath(appName)
|
||||
if (is.dev && resolvedDevAppPath && !isRunnableDevAppPath(resolvedDevAppPath)) {
|
||||
dialog.showErrorBox(
|
||||
'개발 앱 실행 실패',
|
||||
`${appName} 개발 앱 경로를 찾을 수 없습니다.\n${resolvedDevAppPath}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const spawnPath = is.dev && resolvedDevAppPath && isRunnableDevAppPath(resolvedDevAppPath)
|
||||
? resolvedDevAppPath
|
||||
: resolvedExecPath && existsSync(resolvedExecPath)
|
||||
? resolvedExecPath
|
||||
: resolvedExecPath
|
||||
if (spawnPath) {
|
||||
const useDevLauncher = is.dev && spawnPath === resolvedDevAppPath
|
||||
const args = useDevLauncher
|
||||
? ['run', 'dev']
|
||||
: [`--url=${encodeURIComponent(url)}`, ...authLaunchArgs, ...(authProtocolUrl ? [authProtocolUrl] : [])]
|
||||
const execBin = useDevLauncher ? 'npm' : spawnPath
|
||||
|
||||
const childEnv = { ...process.env }
|
||||
if (useDevLauncher) {
|
||||
childEnv.ATNMY_APP_URL = url
|
||||
}
|
||||
if (useDevLauncher && handoffCode) {
|
||||
childEnv.ATNMY_HANDOFF_FILE = writeDevHandoffFile(appName, url, handoffCode, audienceApp)
|
||||
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
|
||||
childEnv.ATNMY_AUDIENCE_APP = audienceApp
|
||||
} else if (handoffCode) {
|
||||
childEnv.ATNMY_HANDOFF_TOKEN = handoffCode
|
||||
childEnv.ATNMY_AUDIENCE_APP = audienceApp
|
||||
}
|
||||
|
||||
delete childEnv.ELECTRON_RENDERER_URL
|
||||
delete childEnv.VITE_DEV_SERVER_URL
|
||||
|
||||
const logPath = useDevLauncher ? join(app.getPath('temp'), `autonomy-studio-${appName.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase()}.dev.log`) : undefined
|
||||
const logFd = logPath ? openSync(logPath, 'a') : undefined
|
||||
if (logPath) {
|
||||
writeFileSync(logPath, [
|
||||
`\n[${new Date().toISOString()}] ${execBin} ${args.join(' ')}`,
|
||||
`[launcher] app=${appName} useDevLauncher=${useDevLauncher ? 'true' : 'false'} hasHandoff=${handoffCode ? 'true' : 'false'} audience=${audienceApp}`,
|
||||
`[launcher] handoffFile=${childEnv.ATNMY_HANDOFF_FILE ?? ''} hasHandoffTokenEnv=${childEnv.ATNMY_HANDOFF_TOKEN ? 'true' : 'false'}`,
|
||||
''
|
||||
].join('\n'), { flag: 'a' })
|
||||
}
|
||||
const child = spawn(execBin, args, {
|
||||
detached: true,
|
||||
stdio: useDevLauncher && logFd !== undefined ? ['ignore', logFd, logFd] : 'ignore',
|
||||
cwd: useDevLauncher ? spawnPath : undefined,
|
||||
env: childEnv,
|
||||
shell: useDevLauncher && process.platform === 'win32'
|
||||
})
|
||||
child.unref()
|
||||
appProcesses.set(appName, child)
|
||||
|
||||
child.on('error', (error) => {
|
||||
appProcesses.delete(appName)
|
||||
dialog.showErrorBox(
|
||||
`${appName} 실행 실패`,
|
||||
`${error instanceof Error ? error.message : '앱 실행에 실패했습니다.'}${logPath ? `\n\n로그: ${logPath}` : ''}`
|
||||
)
|
||||
onAppClosed(appName)
|
||||
})
|
||||
|
||||
child.on('close', () => {
|
||||
appProcesses.delete(appName)
|
||||
onAppClosed(appName)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── fallback: 같은 프로세스 내 BrowserWindow ──
|
||||
const appWin = new BrowserWindow({
|
||||
width,
|
||||
height,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
additionalArguments: [
|
||||
'--app-window',
|
||||
`--app-name=${encodeURIComponent(appName)}`,
|
||||
`--app-url=${encodeURIComponent(url)}`
|
||||
]
|
||||
}
|
||||
})
|
||||
appWindows.set(appName, appWin)
|
||||
attachDevToolsShortcut(appWin)
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
appWin.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
appWin.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
appWin.on('closed', () => {
|
||||
appWindows.delete(appName)
|
||||
onAppClosed(appName)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('focus-app-window', (_, appName: string) => {
|
||||
const win = appWindows.get(appName)
|
||||
if (win && !win.isDestroyed()) { win.focus(); return }
|
||||
|
||||
const child = appProcesses.get(appName)
|
||||
if (!child || child.pid === undefined) return
|
||||
const pid = child.pid
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
exec(`xdotool search --name "${appName}" windowactivate --sync`)
|
||||
} else if (process.platform === 'darwin') {
|
||||
exec(`osascript -e 'tell application "System Events" to set frontmost of (first process whose unix id is ${pid}) to true'`)
|
||||
} else if (process.platform === 'win32') {
|
||||
exec(`powershell -command "(New-Object -ComObject wscript.shell).AppActivate(${pid})"`)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('close-app-window', (_, appName: string) => {
|
||||
const win = appWindows.get(appName)
|
||||
if (win && !win.isDestroyed()) { win.close(); return }
|
||||
|
||||
const child = appProcesses.get(appName)
|
||||
if (child) child.kill()
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
protocol.handle('appdocs', (request) => {
|
||||
const url = new URL(request.url)
|
||||
const docsBase = is.dev
|
||||
? join(process.cwd(), 'resources/docs')
|
||||
: join(__dirname, '../renderer/docs')
|
||||
const filePath = join(docsBase, url.pathname)
|
||||
return net.fetch(`file://${filePath}`)
|
||||
})
|
||||
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
createSplashWindow()
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
const argv = process.argv
|
||||
const isAppWindow = argv.includes('--app-window')
|
||||
const appName = decodeURIComponent(
|
||||
argv.find((a) => a.startsWith('--app-name='))?.slice('--app-name='.length) ?? ''
|
||||
)
|
||||
const appUrl = decodeURIComponent(
|
||||
argv.find((a) => a.startsWith('--app-url='))?.slice('--app-url='.length) ?? ''
|
||||
)
|
||||
|
||||
const appWindowMeta = { isAppWindow, appName, url: appUrl }
|
||||
const launcherTailscale = {
|
||||
status: () => ipcRenderer.invoke('launcher-tailscale-status'),
|
||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) =>
|
||||
ipcRenderer.invoke('launcher-tailscale-login', authKey, loginServer, hostname, forceReset),
|
||||
logout: () => ipcRenderer.invoke('launcher-tailscale-logout'),
|
||||
}
|
||||
const launcherSshKey = {
|
||||
ensure: () => ipcRenderer.invoke('launcher-ssh-key-ensure'),
|
||||
}
|
||||
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('appWindowMeta', appWindowMeta)
|
||||
contextBridge.exposeInMainWorld('launcherTailscale', launcherTailscale)
|
||||
contextBridge.exposeInMainWorld('launcherSshKey', launcherSshKey)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore
|
||||
window.appWindowMeta = appWindowMeta
|
||||
// @ts-ignore
|
||||
window.launcherTailscale = launcherTailscale
|
||||
// @ts-ignore
|
||||
window.launcherSshKey = launcherSshKey
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autonomy Studio</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/gh/moonspam/NanumSquare@1.0/nanumsquare.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import AppWindowShell from './pages/AppWindowShell'
|
||||
import type { AuthSession } from './pages/LoginForm'
|
||||
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const [authSession, setAuthSession] = useState<AuthSession | null>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(AUTH_SESSION_KEY) ?? window.sessionStorage.getItem(AUTH_SESSION_KEY)
|
||||
return raw ? JSON.parse(raw) as AuthSession : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const meta = window.appWindowMeta
|
||||
if (meta?.isAppWindow) {
|
||||
return <AppWindowShell appName={meta.appName} url={meta.url} />
|
||||
}
|
||||
|
||||
const handleSessionUpdate = useCallback((session: AuthSession): void => {
|
||||
setAuthSession(session)
|
||||
if (window.localStorage.getItem(AUTH_SESSION_KEY)) {
|
||||
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
} else if (window.sessionStorage.getItem(AUTH_SESSION_KEY)) {
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback((): void => {
|
||||
window.launcherTailscale?.logout().catch(() => undefined)
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||
setAuthSession(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession?.accessToken) return
|
||||
|
||||
const refreshDelayMs = Math.max(30_000, ((authSession.accessTokenExpiresIn ?? 900) - 60) * 1000)
|
||||
const timer = window.setTimeout(() => {
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
.then((response) => response.ok ? response.json() as Promise<AuthSession> : null)
|
||||
.then((session) => {
|
||||
if (session?.accessToken) {
|
||||
handleSessionUpdate(session)
|
||||
} else {
|
||||
handleLogout()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleLogout()
|
||||
})
|
||||
}, refreshDelayMs)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [authSession, handleLogout, handleSessionUpdate])
|
||||
|
||||
return authSession
|
||||
? <Dashboard authSession={authSession} onSessionUpdate={handleSessionUpdate} onLogout={handleLogout} />
|
||||
: <Login onLogin={setAuthSession} />
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,933 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* @font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicLight.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothic.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicBold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'NanumGothic';
|
||||
src: url('./fonts/NanumGothicExtraBold.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
} */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 99px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-deep: #0a0b0e;
|
||||
--bg-sidebar: #0d0f14;
|
||||
--bg-main: #0f1117;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
--accent: #3b7ffa;
|
||||
--accent-glow: rgba(59, 127, 250, 0.35);
|
||||
--text-primary: #e8eaf0;
|
||||
--text-secondary: rgba(220, 225, 238, 0.76);
|
||||
--text-muted: rgba(157, 163, 175, 0.911);
|
||||
--text-soft: #d6dae3;
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
font-family: 'NanumSquare', sans-serif;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
background: var(--bg-sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(59, 127, 250, 0.15) 40%,
|
||||
rgba(59, 127, 250, 0.15) 60%,
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 13px 24px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
letter-spacing: 0.01em;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.nav-item-unavailable {
|
||||
color: rgba(200, 205, 220, 0.42);
|
||||
cursor: default;
|
||||
}
|
||||
.nav-item-unavailable .nav-item-icon {
|
||||
opacity: 0.38;
|
||||
}
|
||||
.nav-item-unavailable:hover {
|
||||
color: rgba(200, 205, 220, 0.5);
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
}
|
||||
.nav-item-unavailable:hover .nav-item-icon {
|
||||
opacity: 0.46;
|
||||
}
|
||||
.nav-item.active {
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
background: rgba(59, 127, 250, 0.07);
|
||||
}
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.nav-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
margin-right: 10px;
|
||||
opacity: 0.68;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.nav-item:hover .nav-item-icon {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.nav-item.active .nav-item-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-item-unavailable.active {
|
||||
color: rgba(200, 205, 220, 0.54);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
.nav-item-unavailable.active::before {
|
||||
background: rgba(200, 205, 220, 0.24);
|
||||
box-shadow: none;
|
||||
}
|
||||
.nav-item-unavailable.active .nav-item-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.nav-item-collapsed {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* ── Sidebar Collapse Toggle Button ── */
|
||||
.sidebar-collapse-btn {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 50;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.sidebar-collapse-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* ── Main Content ── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background: var(--bg-main);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.main-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 65% 50%, rgba(20, 25, 40, 0.9) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 50% at 20% 50%, rgba(15, 30, 60, 0.3) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Update Button ── */
|
||||
.update-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||
}
|
||||
.update-btn:hover {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.update-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.update-btn:disabled {
|
||||
background: rgba(59, 127, 250, 0.14);
|
||||
border-color: rgba(59, 127, 250, 0.24);
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.update-btn:disabled:hover {
|
||||
background: rgba(59, 127, 250, 0.14);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Updating Button (in-progress state) ── */
|
||||
.updating-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: rgba(59, 127, 250, 0.15);
|
||||
border: 1px solid rgba(59, 127, 250, 0.3);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Update Progress Bar ── */
|
||||
.update-progress-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.update-progress-track {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 99px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.update-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 99px;
|
||||
transition: width 0.2s ease;
|
||||
box-shadow: 0 0 6px rgba(59,127,250,0.6);
|
||||
}
|
||||
.update-progress-pct {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Spinner ── */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Launch Button ── */
|
||||
.launch-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 7px 16px;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.35);
|
||||
}
|
||||
.launch-btn:hover {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.55);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.launch-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.launch-btn:disabled {
|
||||
background: rgba(59, 127, 250, 0.14);
|
||||
border-color: rgba(59, 127, 250, 0.24);
|
||||
color: rgba(255, 255, 255, 0.38);
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
.launch-btn:disabled:hover {
|
||||
background: rgba(59, 127, 250, 0.14);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.launch-pending-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
min-width: 78px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Car Video ── */
|
||||
.car-video {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center bottom;
|
||||
opacity: 0;
|
||||
animation: carFadeIn 1.4s 0.5s ease forwards;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.car-video-shadow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(to right, var(--bg-main) 0%, var(--bg-main) 14%, rgba(15, 17, 23, 0.45) 32%, transparent 52%),
|
||||
linear-gradient(to left, var(--bg-main) 0%, rgba(15, 17, 23, 0.6) 8%, transparent 35%),
|
||||
linear-gradient(to top, rgba(5, 7, 12, 0.78) 0%, transparent 42%),
|
||||
radial-gradient(ellipse 60% 60% at 100% 100%, rgba(5, 7, 12, 0.56) 0%, transparent 14%),
|
||||
radial-gradient(ellipse 100% 100% at 80% 40%, transparent 40%, rgba(5, 7, 12, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.floor-reflection {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: linear-gradient(to top, rgba(8, 10, 18, 0.8) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Text Content ── */
|
||||
.text-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 480px;
|
||||
animation: textReveal 1s 0.2s ease both;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.0;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 0 0 60px rgba(59, 127, 250, 0.1);
|
||||
}
|
||||
.brand-title span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 140px;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, var(--accent), transparent);
|
||||
margin-bottom: 22px;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 13.5px;
|
||||
color: rgba(200, 210, 230, 0.85);
|
||||
line-height: 2.0;
|
||||
letter-spacing: 0.005em;
|
||||
}
|
||||
|
||||
/* ── Verify Code Button ── */
|
||||
.verify-code-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(59, 127, 250, 0.35);
|
||||
background: rgba(59, 127, 250, 0.10);
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
}
|
||||
.verify-code-btn:hover:not(:disabled) {
|
||||
background: rgba(59, 127, 250, 0.2);
|
||||
border-color: rgba(59, 127, 250, 0.6);
|
||||
box-shadow: 0 0 12px rgba(59, 127, 250, 0.25);
|
||||
}
|
||||
.verify-code-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Notice Icon Button ── */
|
||||
.notice-icon-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
.notice-icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.notice-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
}
|
||||
|
||||
.notice-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: 320px;
|
||||
background: rgba(13, 15, 22, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notice-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.notice-item-border {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Notice Panel ── */
|
||||
.notice-panel {
|
||||
padding: 14px 16px;
|
||||
background: rgba(13, 15, 20, 0.75);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.notice-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent-glow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.notice-date {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notice-body {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.notice-link-btn {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notice-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Dashboard ── */
|
||||
.car-bg-radial {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse 90% 70% at 60% 55%, rgba(25, 35, 70, 0.15) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.version-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.login-version-wrap {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 16px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-version-wrap {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 16px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.launcher-update-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.launcher-update-text:hover {
|
||||
text-decoration: underline;
|
||||
color: #6fa4ff;
|
||||
}
|
||||
|
||||
.launcher-update-text:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center bottom;
|
||||
opacity: 0;
|
||||
animation: carFadeIn 1.4s 0.3s ease forwards;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.login-video-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(to bottom, var(--bg-deep) 0%, rgba(10, 11, 14, 0.5) 35%, transparent 60%),
|
||||
linear-gradient(to top, var(--bg-deep) 0%, transparent 40%),
|
||||
linear-gradient(to right, rgba(10, 11, 14, 0.3) 0%, transparent 60%),
|
||||
linear-gradient(to left, rgba(10, 11, 14, 0.5) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(10, 11, 14, 0.7) 0%, transparent 70%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: rgba(13, 15, 20, 0.6);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.login-panel-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 1px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(59, 127, 250, 0.2) 30%, rgba(59, 127, 250, 0.2) 70%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-panel-glow {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 200px;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(59, 127, 250, 0.06) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.animate-reveal {
|
||||
animation: textReveal 0.8s 0.2s ease both;
|
||||
}
|
||||
|
||||
.animate-reveal-late {
|
||||
animation: textReveal 0.8s 0.3s ease both;
|
||||
}
|
||||
|
||||
/* ── Login inputs ── */
|
||||
.login-input-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-input-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-input-field {
|
||||
width: 100%;
|
||||
padding: 11px 16px 11px 40px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input-field::placeholder { color: var(--text-muted); }
|
||||
.login-input-field:focus {
|
||||
border-color: rgba(59, 127, 250, 0.5);
|
||||
background: rgba(59, 127, 250, 0.04);
|
||||
}
|
||||
|
||||
.profile-password-input {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
caret-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-password-input:focus {
|
||||
border-color: rgba(59, 127, 250, 0.5);
|
||||
background: rgba(59, 127, 250, 0.04);
|
||||
}
|
||||
|
||||
.login-input-eye {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.login-input-eye:hover { color: var(--text-secondary); }
|
||||
.login-input-eye svg { width: 16px; height: 16px; }
|
||||
|
||||
.login-checkbox {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-google-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.login-google-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-input {
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: rgba(59, 127, 250, 0.5);
|
||||
background: rgba(59, 127, 250, 0.04);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 11px 0;
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-family: 'NanumGothic', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
box-shadow: 0 4px 20px rgba(59, 127, 250, 0.3);
|
||||
}
|
||||
.login-btn:hover:not(:disabled) {
|
||||
background: #5592ff;
|
||||
box-shadow: 0 6px 28px rgba(59, 127, 250, 0.5);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.login-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Typing Cursor ── */
|
||||
.typing-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background: var(--text-muted);
|
||||
border-radius: 1px;
|
||||
animation: cursorBlink 0.9s step-end infinite;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── Running State ── */
|
||||
.running-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 14px 6px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(34, 197, 94, 0.07);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.running-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5);
|
||||
animation: running-pulse 1.8s ease infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes running-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); }
|
||||
55% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
|
||||
}
|
||||
|
||||
/* ── Animations ── */
|
||||
@keyframes textReveal {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes carFadeIn {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export const EnvelopeIcon = (): React.JSX.Element => (
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const LockIcon = (): React.JSX.Element => (
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const PersonIcon = (): React.JSX.Element => (
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const EyeToggle = ({ show, onClick }: { show: boolean; onClick: () => void }): React.JSX.Element => (
|
||||
<button type="button" onClick={onClick} className="login-input-eye">
|
||||
{show ? (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
export const GoogleIcon = (): React.JSX.Element => (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
import icon from '../assets/icon.png'
|
||||
import { Minus, Square, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface TitleBarProps {
|
||||
appName?: string
|
||||
}
|
||||
|
||||
function TitleBar({ appName }: TitleBarProps): React.JSX.Element {
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const handleMinimize = (): void => window.electron.ipcRenderer.send('window-minimize')
|
||||
const handleMaximize = (): void => window.electron.ipcRenderer.send('window-maximize')
|
||||
const handleClose = (): void => setShowCloseConfirm(true)
|
||||
const handleConfirmClose = (): void => window.electron.ipcRenderer.send('window-close')
|
||||
|
||||
const winBtn =
|
||||
'flex h-[20px] w-[20px] items-center justify-center text-white/50 hover:text-white transition cursor-pointer'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex h-[38px] shrink-0 items-center justify-between border-b border-white/10 bg-[#111520] px-4 pl-5 select-none"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* 좌측 */}
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={icon}
|
||||
alt="logo"
|
||||
className="mr-2 h-[18px] w-auto -translate-y-[2px] opacity-90"
|
||||
/>
|
||||
<span className="font-['Rajdhani',sans-serif] text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">
|
||||
{appName ?? 'Autonomy Studio'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 우측 */}
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<button onClick={handleMinimize} className={winBtn}>
|
||||
<Minus size={12} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<button onClick={handleMaximize} className={winBtn}>
|
||||
<Square size={11} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`${winBtn} hover:text-red-400`}
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCloseConfirm && (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-[38px] z-[10000] flex items-center justify-center bg-[rgba(2,6,18,0.48)] backdrop-blur-[10px]"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
|
||||
>
|
||||
<div className="relative w-[320px] overflow-hidden rounded-lg border border-[rgba(255,255,255,0.18)] bg-[rgba(22,27,39,0.72)] shadow-[0_28px_90px_rgba(0,0,0,0.58),inset_0_1px_0_rgba(255,255,255,0.12)] backdrop-blur-2xl">
|
||||
<header className="flex h-[54px] items-center justify-between px-5">
|
||||
<h2 className="text-[15px] font-bold text-[var(--text-primary)]">Autonomy Studio</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCloseConfirm(false)}
|
||||
className="grid h-8 w-8 place-items-center rounded-md text-white/55 hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="px-5 py-5">
|
||||
<p className="text-[13px] leading-relaxed text-[var(--text-secondary)]">
|
||||
앱을 종료하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCloseConfirm(false)}
|
||||
className="rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-[var(--text-secondary)] hover:bg-white/[0.06] hover:text-white cursor-pointer transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmClose}
|
||||
className="rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 hover:border-red-400/50 hover:bg-red-500/24 cursor-pointer transition-colors"
|
||||
>
|
||||
종료
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type TooltipProps = {
|
||||
label: string
|
||||
children: ReactNode
|
||||
side?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
function Tooltip({ label, children, side = 'bottom' }: TooltipProps): React.JSX.Element {
|
||||
const sideClass = side === 'top'
|
||||
? 'bottom-[calc(100%+8px)]'
|
||||
: 'top-[calc(100%+8px)]'
|
||||
const arrowClass = side === 'top'
|
||||
? 'bottom-[-4px] border-b border-r'
|
||||
: 'top-[-4px] border-l border-t'
|
||||
|
||||
return (
|
||||
<span className="group/tooltip relative inline-flex">
|
||||
{children}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={`pointer-events-none absolute right-0 ${sideClass} z-[9999] whitespace-nowrap rounded-md border border-[rgba(255,255,255,0.1)] bg-[#111827] px-2.5 py-1.5 text-[11px] font-semibold leading-none text-white/85 opacity-0 shadow-[0_14px_40px_rgba(0,0,0,0.35)] transition duration-150 group-hover/tooltip:opacity-100 group-focus-within/tooltip:opacity-100`}
|
||||
>
|
||||
{label}
|
||||
<span className={`absolute right-3 h-2 w-2 rotate-45 border-[rgba(255,255,255,0.1)] bg-[#111827] ${arrowClass}`} />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface LauncherApp {
|
||||
appKey: string
|
||||
displayName: string
|
||||
description: string
|
||||
subtitle?: string | null
|
||||
detailUrl?: string | null
|
||||
videoPath?: string | null
|
||||
iconKey: string
|
||||
launchUrl?: string | null
|
||||
devAppPath?: string | null
|
||||
execPath?: string | null
|
||||
sortOrder?: number
|
||||
canView?: boolean
|
||||
canLaunch?: boolean
|
||||
}
|
||||
|
||||
export const FALLBACK_APPS: LauncherApp[] = [
|
||||
{
|
||||
appKey: 'ADS IDE',
|
||||
displayName: 'ADS IDE',
|
||||
description: '클라우드 기반에서 자율주행 소프트웨어를 설계·빌드·실행까지 빠르게 수행할 수 있는 통합 개발 플랫폼으로, 오픈소스 ADS 기반의 유연한 구조를 통해 기능 수정과 배포가 가능하며 다양한 실차 및 시뮬레이션 환경을 지원합니다.',
|
||||
subtitle: '자율주행 모빌리티 소프트웨어\n통합 개발 환경 (IDE)',
|
||||
detailUrl: 'https://www.futuredrive.net/solutions/astudio',
|
||||
videoPath: './video/ads_ide.mp4',
|
||||
iconKey: 'code',
|
||||
launchUrl: 'http://localhost:5175',
|
||||
devAppPath: './apps/ads-ide/ads-ide-fe',
|
||||
execPath: './ADS-IDE-1.1.0.AppImage',
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
appKey: 'LMS',
|
||||
displayName: 'LMS',
|
||||
description: '자율주행 교육 과정, 실습 콘텐츠, 사용자 학습 이력을 통합 관리하는 학습 관리 시스템입니다.',
|
||||
subtitle: '자율주행 교육 콘텐츠\n학습 관리 시스템',
|
||||
videoPath: './video/lms.mp4',
|
||||
iconKey: 'graduation',
|
||||
launchUrl: 'http://localhost:5176',
|
||||
devAppPath: './apps/lms/lms-fe',
|
||||
execPath: './LMS-1.0.0.AppImage',
|
||||
sortOrder: 20,
|
||||
},
|
||||
{ appKey: 'Data Management', displayName: 'Data Management', description: '주행 데이터 수집 · 저장 · 분석 플랫폼', videoPath: './video/data_management.mp4', iconKey: 'bar-chart', sortOrder: 30 },
|
||||
{ appKey: 'HMI', displayName: 'HMI', description: '차량 인터페이스 모니터링 및 제어', videoPath: './video/hmi.mp4', iconKey: 'monitor', sortOrder: 40 },
|
||||
{ appKey: 'FMS', displayName: 'FMS', description: '플릿 운영 및 차량 관제 시스템', videoPath: './video/fms.mp4', iconKey: 'truck', sortOrder: 50 },
|
||||
{ appKey: 'ADS Education', displayName: 'ADS Education', description: '자율주행 기술 학습 및 교육 콘텐츠', videoPath: './video/ads_education.mp4', iconKey: 'graduation', sortOrder: 60 },
|
||||
{ appKey: 'AI Benchmark', displayName: 'AI Benchmark', description: 'AI 모델 성능 평가 및 비교 분석', videoPath: './video/ai_benchmark.mp4', iconKey: 'activity', sortOrder: 70 },
|
||||
{ appKey: 'Calibration', displayName: 'Calibration', description: '센서 캘리브레이션 및 설정 도구', videoPath: './video/calibration.mp4', iconKey: 'sliders', sortOrder: 80 },
|
||||
{ appKey: 'IP Repository', displayName: 'IP Repository', description: '지식재산 및 소프트웨어 자산 저장소', videoPath: './video/ip_repository.mp4', iconKey: 'shopping-cart', sortOrder: 90 },
|
||||
{ appKey: 'HDMAP Builder', displayName: 'HDMAP Builder', description: '고정밀 지도 생성 및 편집 도구', videoPath: './video/hdmap_builder.mp4', iconKey: 'map', sortOrder: 100 },
|
||||
]
|
||||
Vendored
+66
@@ -0,0 +1,66 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { ElectronAPI } from '@electron-toolkit/preload'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
appWindowMeta: {
|
||||
isAppWindow: boolean
|
||||
appName: string
|
||||
url: string
|
||||
}
|
||||
launcherTailscale?: {
|
||||
status: () => Promise<{
|
||||
connected: boolean
|
||||
ip: string
|
||||
backendState: string
|
||||
online?: boolean
|
||||
health?: string[]
|
||||
loginName?: string
|
||||
userName?: string
|
||||
peers?: Array<{
|
||||
id: string
|
||||
hostName: string
|
||||
dnsName?: string
|
||||
os?: string
|
||||
userName?: string
|
||||
loginName?: string
|
||||
ip: string
|
||||
ips: string[]
|
||||
online: boolean
|
||||
active: boolean
|
||||
lastSeen?: string
|
||||
relay?: string
|
||||
currentAddress?: string
|
||||
}>
|
||||
error?: string
|
||||
}>
|
||||
login: (authKey: string, loginServer: string, hostname?: string, forceReset?: boolean) => Promise<{
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
logout: () => Promise<{
|
||||
ok: boolean
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
launcherSshKey?: {
|
||||
ensure: () => Promise<{
|
||||
publicKey: string
|
||||
publicKeyPath: string
|
||||
privateKeyPath: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION: string
|
||||
readonly VITE_ATNMY_AUTH_API_BASE_URL?: string
|
||||
readonly VITE_ATNMY_STDO_API_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './assets/main.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { LauncherApp } from '../config/apps'
|
||||
|
||||
const DEFAULT_DETAIL_URL = 'https://www.futuredrive.net/solutions/astudio'
|
||||
|
||||
function resolvePackagedAssetPath(path?: string | null): string | null {
|
||||
if (!path) return null
|
||||
if (/^(https?:|file:|data:|blob:)/i.test(path)) return path
|
||||
if (path.startsWith('/video/')) return `.${path}`
|
||||
return path
|
||||
}
|
||||
|
||||
interface AppViewProps {
|
||||
app: LauncherApp
|
||||
isRunning: boolean
|
||||
onShowManual: () => void
|
||||
onFocusApp: () => void
|
||||
onCloseApp: () => void
|
||||
}
|
||||
|
||||
function AppView({ app, isRunning, onShowManual, onFocusApp, onCloseApp }: AppViewProps): React.JSX.Element {
|
||||
const [titleFirst, titleRest] = app.displayName.includes(' ')
|
||||
? [app.displayName.split(' ')[0], app.displayName.split(' ').slice(1).join(' ')]
|
||||
: [app.displayName, null]
|
||||
const appTitle = titleRest
|
||||
? <>{titleFirst}<br /><span>{titleRest}</span></>
|
||||
: <span>{titleFirst}</span>
|
||||
const videoPath = resolvePackagedAssetPath(app.videoPath)
|
||||
|
||||
if (isRunning) {
|
||||
return (
|
||||
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_65%_55%_at_50%_45%,rgba(59,127,250,0.05)_0%,transparent_65%)]" />
|
||||
|
||||
<div className="text-content">
|
||||
<h1 className="brand-title">
|
||||
{appTitle}
|
||||
</h1>
|
||||
|
||||
<div className="running-badge">
|
||||
<span className="running-dot" />
|
||||
<span className="text-[12px] font-medium tracking-[0.04em]" style={{ color: '#22c55e' }}>앱 실행 중</span>
|
||||
</div>
|
||||
|
||||
<div className="divider" />
|
||||
<p className="description" style={{ opacity: 0.55 }}>{app.description}</p>
|
||||
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFocusApp}
|
||||
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
|
||||
>
|
||||
앱 창으로 이동
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseApp}
|
||||
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,80,80,0.2)] bg-[rgba(255,80,80,0.04)] text-[12px] hover:bg-[rgba(255,80,80,0.1)] hover:border-[rgba(255,80,80,0.35)] cursor-pointer transition-all duration-200"
|
||||
style={{ color: 'rgba(255,110,110,0.75)' }}
|
||||
>
|
||||
앱 종료
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-start relative overflow-hidden pt-[80px] pb-[80px] pl-[52px]">
|
||||
|
||||
{/* 비디오 배경 */}
|
||||
{videoPath && (
|
||||
<>
|
||||
<video
|
||||
key={videoPath}
|
||||
className="car-video"
|
||||
src={videoPath}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
onError={(e) => { (e.target as HTMLVideoElement).style.display = 'none' }}
|
||||
/>
|
||||
<div className="car-video-shadow" />
|
||||
<div className="floor-reflection" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!videoPath && (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_50%,rgba(59,127,250,0.04)_0%,transparent_70%)]" />
|
||||
)}
|
||||
|
||||
{/* 텍스트 */}
|
||||
<div className="text-content">
|
||||
<h1 className="brand-title">
|
||||
{appTitle}
|
||||
</h1>
|
||||
|
||||
{app.subtitle && (
|
||||
<p className="subtitle">
|
||||
{app.subtitle.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>{line}{i < arr.length - 1 && <br />}</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="divider" />
|
||||
<p className="description">{app.description}</p>
|
||||
<div className="mt-5 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.open(app.detailUrl ?? DEFAULT_DETAIL_URL, '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(59,127,250,0.35)] bg-[rgba(59,127,250,0.08)] text-[12px] text-[var(--accent)] hover:bg-[rgba(59,127,250,0.18)] hover:border-[rgba(59,127,250,0.6)] cursor-pointer transition-all duration-200"
|
||||
>
|
||||
자세히 보기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowManual}
|
||||
className="inline-flex items-center px-4 py-[7px] rounded-full border border-[rgba(255,255,255,0.1)] bg-[rgba(255,255,255,0.04)] text-[12px] text-[var(--text-secondary)] hover:bg-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.18)] cursor-pointer transition-all duration-200"
|
||||
>
|
||||
사용 매뉴얼
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppView
|
||||
@@ -0,0 +1,20 @@
|
||||
import TitleBar from '../components/TitleBar'
|
||||
|
||||
interface AppWindowShellProps {
|
||||
appName: string
|
||||
url: string
|
||||
}
|
||||
|
||||
function AppWindowShell({ appName, url }: AppWindowShellProps): React.JSX.Element {
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col overflow-hidden bg-[#0a0b0e]">
|
||||
<TitleBar appName={appName} />
|
||||
<iframe
|
||||
src={url}
|
||||
style={{ flex: 1, border: 'none', display: 'block', background: '#0a0b0e' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppWindowShell
|
||||
@@ -0,0 +1,758 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import Sidebar from '../components/Sidebar'
|
||||
import Tooltip from '../components/Tooltip'
|
||||
import AppView from './AppView'
|
||||
import type { LauncherApp } from '../config/apps'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
|
||||
const version = import.meta.env.VITE_APP_VERSION
|
||||
|
||||
interface DashboardProps {
|
||||
authSession: AuthSession
|
||||
onSessionUpdate: (session: AuthSession) => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
const NOTICE_READ_STORAGE_KEY = 'atnmy.notice.readSignatures'
|
||||
const LAST_LAUNCHED_APP_STORAGE_KEY = 'atnmy.launcher.lastLaunchedApp'
|
||||
const IS_DEV = import.meta.env.DEV
|
||||
|
||||
const resolveHandoffAudienceApp = (appKey: string): string | null => {
|
||||
const normalizedAppKey = appKey.trim().toLowerCase()
|
||||
if (normalizedAppKey === 'lms') return 'LMS'
|
||||
if (normalizedAppKey === 'ads ide' || normalizedAppKey === 'ads-ide') return 'ADS IDE'
|
||||
return null
|
||||
}
|
||||
|
||||
type AppNotice = {
|
||||
id: string
|
||||
date: string
|
||||
title: string
|
||||
body: string
|
||||
linkUrl?: string | null
|
||||
}
|
||||
|
||||
type AppUpdateAction = 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
|
||||
|
||||
type AppUpdateStatus = {
|
||||
appKey: string
|
||||
displayName?: string
|
||||
installed: boolean
|
||||
localVersion: string | null
|
||||
latestVersion: string | null
|
||||
status: 'NOT_INSTALLED' | 'UPDATE_AVAILABLE' | 'READY' | 'DISABLED' | 'UNKNOWN_APP'
|
||||
action: AppUpdateAction
|
||||
updateRequired: boolean
|
||||
downloadUrl?: string | null
|
||||
releaseNotes?: string | null
|
||||
canLaunch?: boolean
|
||||
}
|
||||
|
||||
type InstallationInfo = {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
}
|
||||
|
||||
type AuthMeResponse = {
|
||||
user?: AuthSession['user']
|
||||
}
|
||||
|
||||
function Dashboard({ authSession, onSessionUpdate, onLogout }: DashboardProps): React.JSX.Element {
|
||||
const [apps, setApps] = useState<LauncherApp[]>([])
|
||||
const [activeApp, setActiveApp] = useState('')
|
||||
const [appNotices, setAppNotices] = useState<AppNotice[]>([])
|
||||
const [readNoticeSignatures, setReadNoticeSignatures] = useState<Record<string, string>>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(NOTICE_READ_STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) as Record<string, string> : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
const [appStatus, setAppStatus] = useState<AppUpdateStatus | null>(null)
|
||||
const [launcherStatus, setLauncherStatus] = useState<AppUpdateStatus | null>(null)
|
||||
const [noticeOpen, setNoticeOpen] = useState(false)
|
||||
const [updatingApps, setUpdatingApps] = useState<Record<string, boolean>>({})
|
||||
const [updateProgressByApp, setUpdateProgressByApp] = useState<Record<string, number>>({})
|
||||
const [localVersions, setLocalVersions] = useState<Record<string, string>>({})
|
||||
const [installedOverrides, setInstalledOverrides] = useState<Record<string, boolean>>({})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [showManual, setShowManual] = useState(false)
|
||||
const [runningApps, setRunningApps] = useState<Set<string>>(new Set())
|
||||
const [launchingApp, setLaunchingApp] = useState<string | null>(null)
|
||||
const noticeRef = useRef<HTMLDivElement>(null)
|
||||
const initialAppSelectionDoneRef = useRef(false)
|
||||
|
||||
const appConfig = apps.find((app) => app.appKey === activeApp) ?? null
|
||||
const hasNotices = appNotices.length > 0
|
||||
const noticeSignature = appNotices.map((notice) => notice.id).join('|')
|
||||
const hasUnreadNotices = hasNotices && readNoticeSignatures[activeApp] !== noticeSignature
|
||||
const isRunning = runningApps.has(activeApp)
|
||||
const activeLocalVersion = localVersions[activeApp]
|
||||
const appAction = appStatus?.action ?? 'UNAVAILABLE'
|
||||
const hasAppPermission = appConfig !== null && appConfig.canLaunch !== false && appStatus?.canLaunch !== false
|
||||
const canLaunch = hasAppPermission && appAction === 'LAUNCH' && Boolean(appConfig?.launchUrl)
|
||||
const hasAnyUpdatingApp = Object.values(updatingApps).some(Boolean)
|
||||
const activeAppUpdating = Boolean(updatingApps[activeApp])
|
||||
const activeAppLaunching = launchingApp === activeApp
|
||||
const activeUpdateProgress = updateProgressByApp[activeApp] ?? 0
|
||||
const launcherUpdateAvailable = launcherStatus?.action === 'UPDATE'
|
||||
const launcherUpdateMessage = launcherUpdateAvailable
|
||||
? `v${launcherStatus?.latestVersion ?? ''} 업데이트 가능`
|
||||
: launcherStatus
|
||||
? '최신버전'
|
||||
: '업데이트 상태 확인중'
|
||||
|
||||
const refreshSession = useCallback(async (): Promise<AuthSession | null> => {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!response.ok) return null
|
||||
|
||||
const session = await response.json() as AuthSession
|
||||
onSessionUpdate(session)
|
||||
return session
|
||||
}, [onSessionUpdate])
|
||||
|
||||
const fetchWithAuthRefresh = useCallback(async (url: string, options: RequestInit = {}): Promise<Response> => {
|
||||
const request = (accessToken: string): Promise<Response> => fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers ?? {}),
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
let response = await request(authSession.accessToken)
|
||||
if (response.status !== 401) return response
|
||||
|
||||
const refreshed = await refreshSession()
|
||||
if (!refreshed) {
|
||||
onLogout()
|
||||
return response
|
||||
}
|
||||
|
||||
response = await request(refreshed.accessToken)
|
||||
return response
|
||||
}, [authSession.accessToken, onLogout, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadUserProfile = async (): Promise<void> => {
|
||||
let session = authSession
|
||||
let response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await refreshSession()
|
||||
if (!refreshed) {
|
||||
if (!cancelled) onLogout()
|
||||
return
|
||||
}
|
||||
|
||||
session = refreshed
|
||||
response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.accessToken}`,
|
||||
},
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const payload = await response.json().catch(() => null) as AuthMeResponse | null
|
||||
if (!cancelled && payload?.user) {
|
||||
const nextSession = {
|
||||
...session,
|
||||
user: payload.user,
|
||||
}
|
||||
if (JSON.stringify(nextSession.user) === JSON.stringify(authSession.user) && nextSession.accessToken === authSession.accessToken) {
|
||||
return
|
||||
}
|
||||
onSessionUpdate({
|
||||
...nextSession,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void loadUserProfile()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [authSession, onLogout, onSessionUpdate, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((payload: { apps?: LauncherApp[] } | null) => {
|
||||
if (cancelled || !payload?.apps?.length) return
|
||||
setApps(payload.apps)
|
||||
const currentApp = payload.apps.find((app) => app.appKey === activeApp)
|
||||
const currentAppAvailable = currentApp?.canLaunch !== false && currentApp?.canView !== false
|
||||
const firstAvailableApp = payload.apps.find((app) => app.canView !== false && app.canLaunch !== false)
|
||||
const lastLaunchedAppKey = window.localStorage.getItem(LAST_LAUNCHED_APP_STORAGE_KEY)
|
||||
const lastLaunchedApp = lastLaunchedAppKey
|
||||
? payload.apps.find((app) => app.appKey === lastLaunchedAppKey && app.canView !== false && app.canLaunch !== false)
|
||||
: undefined
|
||||
|
||||
if (!initialAppSelectionDoneRef.current) {
|
||||
initialAppSelectionDoneRef.current = true
|
||||
setActiveApp(lastLaunchedApp?.appKey ?? firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentApp || !currentAppAvailable) {
|
||||
setActiveApp(firstAvailableApp?.appKey ?? currentApp?.appKey ?? payload.apps[0].appKey)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeApp, fetchWithAuthRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
setNoticeOpen(false)
|
||||
setAppStatus(null)
|
||||
setShowManual(false)
|
||||
}, [activeApp])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (!activeApp) {
|
||||
setAppNotices([])
|
||||
return
|
||||
}
|
||||
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/apps/${encodeURIComponent(activeApp)}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((payload: { notices?: AppNotice[] } | null) => {
|
||||
if (!cancelled) setAppNotices(payload?.notices ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAppNotices([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeApp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeApp || !appConfig) {
|
||||
setAppStatus(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const loadStatus = async (): Promise<void> => {
|
||||
let installation: InstallationInfo = { installed: false, version: null, path: null }
|
||||
|
||||
try {
|
||||
installation = await window.electron.ipcRenderer.invoke('get-app-installation', {
|
||||
appName: activeApp,
|
||||
execPath: appConfig.execPath,
|
||||
devAppPath: appConfig.devAppPath,
|
||||
}) as InstallationInfo
|
||||
} catch {
|
||||
installation = { installed: false, version: null, path: null }
|
||||
}
|
||||
|
||||
if (IS_DEV && appConfig.devAppPath && installation.installed) {
|
||||
if (!cancelled) {
|
||||
setAppStatus({
|
||||
appKey: activeApp,
|
||||
displayName: appConfig.displayName,
|
||||
installed: true,
|
||||
localVersion: installation.version,
|
||||
latestVersion: installation.version,
|
||||
status: 'READY',
|
||||
action: 'LAUNCH',
|
||||
updateRequired: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const installed = installedOverrides[activeApp] ?? installation.installed
|
||||
const currentVersion = installation.version ?? activeLocalVersion ?? ''
|
||||
if (installation.version) {
|
||||
setLocalVersions((prev) => ({ ...prev, [activeApp]: installation.version as string }))
|
||||
}
|
||||
const query = new URLSearchParams({
|
||||
currentVersion,
|
||||
installed: String(installed),
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuthRefresh(`${LAUNCHER_API_BASE_URL}/api/apps/${encodeURIComponent(activeApp)}/update?${query}`)
|
||||
const status = response.ok ? await response.json() as AppUpdateStatus : null
|
||||
if (!cancelled) setAppStatus(status)
|
||||
} catch {
|
||||
if (!cancelled) setAppStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
loadStatus()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeApp, activeLocalVersion, appConfig, fetchWithAuthRefresh, installedOverrides])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const query = new URLSearchParams({
|
||||
currentVersion: version,
|
||||
installed: 'true',
|
||||
})
|
||||
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((status: AppUpdateStatus | null) => {
|
||||
if (!cancelled) setLauncherStatus(status)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLauncherStatus(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (_event: unknown, appName: string): void => {
|
||||
setRunningApps((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(appName)
|
||||
return next
|
||||
})
|
||||
}
|
||||
window.electron.ipcRenderer.on('app-window-closed', handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener('app-window-closed', handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
|
||||
setUpdateProgressByApp((prev) => ({
|
||||
...prev,
|
||||
[payload.appName]: payload.progress,
|
||||
}))
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on('app-download-progress', handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const completeLocalUpdate = useCallback((appKey: string, latestVersion?: string | null): void => {
|
||||
if (!latestVersion) return
|
||||
setLocalVersions((prev) => ({ ...prev, [appKey]: latestVersion }))
|
||||
setInstalledOverrides((prev) => ({ ...prev, [appKey]: true }))
|
||||
}, [])
|
||||
|
||||
const markAppNoticesRead = useCallback((): void => {
|
||||
if (!hasNotices) return
|
||||
|
||||
setReadNoticeSignatures((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
[activeApp]: noticeSignature,
|
||||
}
|
||||
window.localStorage.setItem(NOTICE_READ_STORAGE_KEY, JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}, [activeApp, hasNotices, noticeSignature])
|
||||
|
||||
const handleUpdate = useCallback(async (
|
||||
targetApp: string,
|
||||
latestVersion?: string | null,
|
||||
downloadUrl?: string | null,
|
||||
) => {
|
||||
if (!downloadUrl) {
|
||||
alert('다운로드 URL이 없습니다.')
|
||||
return
|
||||
}
|
||||
if (hasAnyUpdatingApp) {
|
||||
alert('이미 업데이트가 진행 중입니다.')
|
||||
return
|
||||
}
|
||||
if (targetApp !== 'launcher' && !appConfig) {
|
||||
alert('선택된 앱이 없습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
setUpdatingApps((prev) => ({ ...prev, [targetApp]: true }))
|
||||
setUpdateProgressByApp((prev) => ({ ...prev, [targetApp]: 0 }))
|
||||
|
||||
try {
|
||||
if (targetApp === 'launcher') {
|
||||
const result = await window.electron.ipcRenderer.invoke('install-launcher-update', {
|
||||
downloadUrl,
|
||||
latestVersion,
|
||||
}) as InstallationInfo
|
||||
const installedVersion = result.version ?? latestVersion
|
||||
completeLocalUpdate(targetApp, installedVersion)
|
||||
setLauncherStatus((prev) => prev
|
||||
? {
|
||||
...prev,
|
||||
installed: true,
|
||||
localVersion: installedVersion ?? prev.latestVersion,
|
||||
status: 'READY',
|
||||
action: 'LAUNCH',
|
||||
updateRequired: false,
|
||||
}
|
||||
: prev)
|
||||
} else {
|
||||
const result = await window.electron.ipcRenderer.invoke('download-app', {
|
||||
appName: targetApp,
|
||||
downloadUrl,
|
||||
execPath: appConfig?.execPath,
|
||||
latestVersion,
|
||||
}) as InstallationInfo
|
||||
|
||||
if (result.version) {
|
||||
completeLocalUpdate(targetApp, result.version)
|
||||
} else {
|
||||
completeLocalUpdate(targetApp, latestVersion)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '다운로드에 실패했습니다.'
|
||||
alert(message)
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setUpdatingApps((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[targetApp]
|
||||
return next
|
||||
})
|
||||
setUpdateProgressByApp((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[targetApp]
|
||||
return next
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
}, [appConfig, completeLocalUpdate, hasAnyUpdatingApp])
|
||||
|
||||
const createHandoffToken = useCallback(async (audienceApp: string): Promise<string | null> => {
|
||||
const requestHandoff = async (accessToken: string): Promise<Response> => {
|
||||
return fetch(`${LAUNCHER_API_BASE_URL}/api/auth/handoff`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ audienceApp }),
|
||||
})
|
||||
}
|
||||
|
||||
let response = await requestHandoff(authSession.accessToken)
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshResponse = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (refreshResponse.ok) {
|
||||
const nextSession = await refreshResponse.json() as AuthSession
|
||||
onSessionUpdate(nextSession)
|
||||
response = await requestHandoff(nextSession.accessToken)
|
||||
} else {
|
||||
alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
|
||||
onLogout()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { message?: string; error?: string } | null
|
||||
const detail = payload?.message ?? payload?.error ?? `HTTP ${response.status}`
|
||||
alert(`${audienceApp} 인증 연동에 실패했습니다. (${detail})`)
|
||||
return null
|
||||
}
|
||||
|
||||
const handoff = await response.json() as { handoffToken: string }
|
||||
return handoff.handoffToken
|
||||
}, [authSession.accessToken, onLogout, onSessionUpdate])
|
||||
|
||||
const handleLaunch = useCallback(async () => {
|
||||
if (!appConfig || !canLaunch || launchingApp) return
|
||||
|
||||
setLaunchingApp(activeApp)
|
||||
const launchDelay = new Promise((resolve) => window.setTimeout(resolve, 1500))
|
||||
|
||||
let handoffCode: string | undefined
|
||||
try {
|
||||
const handoffAudienceApp = resolveHandoffAudienceApp(activeApp)
|
||||
if (handoffAudienceApp) {
|
||||
const [handoffToken] = await Promise.all([
|
||||
createHandoffToken(handoffAudienceApp),
|
||||
launchDelay,
|
||||
])
|
||||
handoffCode = handoffToken ?? undefined
|
||||
if (!handoffCode) {
|
||||
setLaunchingApp(null)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
await launchDelay
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.send('launch-app', {
|
||||
appName: activeApp,
|
||||
url: appConfig.launchUrl,
|
||||
devAppPath: appConfig.devAppPath,
|
||||
execPath: appConfig.execPath,
|
||||
handoffCode,
|
||||
audienceApp: resolveHandoffAudienceApp(activeApp) ?? activeApp,
|
||||
})
|
||||
window.localStorage.setItem(LAST_LAUNCHED_APP_STORAGE_KEY, activeApp)
|
||||
setRunningApps((prev) => new Set([...prev, activeApp]))
|
||||
} finally {
|
||||
setLaunchingApp(null)
|
||||
}
|
||||
}, [activeApp, appConfig, canLaunch, createHandoffToken, launchingApp])
|
||||
|
||||
const handleFocusApp = useCallback(() => {
|
||||
window.electron.ipcRenderer.send('focus-app-window', activeApp)
|
||||
}, [activeApp])
|
||||
|
||||
const handleCloseApp = useCallback(() => {
|
||||
window.electron.ipcRenderer.send('close-app-window', activeApp)
|
||||
}, [activeApp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!noticeOpen) return
|
||||
const handler = (e: MouseEvent): void => {
|
||||
if (noticeRef.current && !noticeRef.current.contains(e.target as Node)) {
|
||||
setNoticeOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [noticeOpen])
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
|
||||
<TitleBar />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
apps={apps}
|
||||
userName={authSession.user.name || authSession.user.email.split('@')[0] || '사용자'}
|
||||
userEmail={authSession.user.email}
|
||||
userRole={authSession.user.role}
|
||||
userCreatedAt={authSession.user.createdAt}
|
||||
userLastLoginAt={authSession.user.lastLoginAt}
|
||||
primaryAffiliation={authSession.user.primaryAffiliation}
|
||||
currentLicense={authSession.user.currentLicense}
|
||||
activeApp={activeApp}
|
||||
onAppChange={setActiveApp}
|
||||
onLogout={onLogout}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
||||
launcherUpdateAvailable={launcherUpdateAvailable}
|
||||
launcherUpdating={Boolean(updatingApps.launcher)}
|
||||
launcherUpdateMessage={launcherUpdateMessage}
|
||||
onLauncherUpdate={() => handleUpdate('launcher', launcherStatus?.latestVersion, launcherStatus?.downloadUrl)}
|
||||
authAccessToken={authSession.accessToken}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
{/* 우상단 영역 */}
|
||||
<div className="absolute top-[28px] right-[32px] z-10 flex items-start gap-3">
|
||||
|
||||
{!showManual && (
|
||||
<>
|
||||
{/* 공지사항 버튼 + 드롭다운 */}
|
||||
<div className="relative" ref={noticeRef}>
|
||||
<Tooltip label="공지사항">
|
||||
<button
|
||||
onClick={() => {
|
||||
markAppNoticesRead()
|
||||
setNoticeOpen((v) => !v)
|
||||
}}
|
||||
className="notice-icon-btn"
|
||||
style={{ opacity: hasNotices ? 1 : 0.35, cursor: hasNotices ? 'pointer' : 'default' }}
|
||||
disabled={!hasNotices}
|
||||
>
|
||||
{hasUnreadNotices && <span className="notice-badge" />}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{noticeOpen && hasNotices && (
|
||||
<div className="notice-dropdown">
|
||||
<div className="flex items-center justify-between mb-3 px-4 pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="notice-dot" />
|
||||
<span className="notice-label">공지사항</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-[var(--text-muted)]">{appNotices.length}건</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{appNotices.map((n, i) => (
|
||||
<div key={n.id} className={`notice-item${i < appNotices.length - 1 ? ' notice-item-border' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="notice-title">{n.title}</p>
|
||||
<span className="notice-date">{n.date}</span>
|
||||
</div>
|
||||
<p className="notice-body">{n.body}</p>
|
||||
{n.linkUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="notice-link-btn"
|
||||
onClick={() => window.open(n.linkUrl ?? '', '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
자세히보기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 업데이트 / 실행 버튼 — 앱 실행 중일 때는 숨김 */}
|
||||
{!isRunning && hasAppPermission && (
|
||||
appAction === 'INSTALL' || appAction === 'UPDATE' ? (
|
||||
<div className="flex flex-col items-end gap-[7px]">
|
||||
{activeAppUpdating ? (
|
||||
<button className="updating-btn" disabled>
|
||||
<svg className="spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
|
||||
<path d="M12 2a10 10 0 1 0 10 10" />
|
||||
</svg>
|
||||
{appAction === 'INSTALL' ? '설치중' : '업데이트중'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="update-btn" onClick={() => handleUpdate(activeApp, appStatus?.latestVersion, appStatus?.downloadUrl)} disabled={hasAnyUpdatingApp || !hasAppPermission}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{appAction === 'INSTALL' ? '설치' : '업데이트'}
|
||||
</button>
|
||||
)}
|
||||
{activeAppUpdating && (
|
||||
<div className="update-progress-wrap">
|
||||
<div className="update-progress-track">
|
||||
<div className="update-progress-fill" style={{ width: `${activeUpdateProgress}%` }} />
|
||||
</div>
|
||||
<span className="update-progress-pct">{activeUpdateProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : canLaunch ? (
|
||||
activeAppLaunching ? (
|
||||
<button className="launch-pending-btn" disabled>
|
||||
실행중
|
||||
</button>
|
||||
) : (
|
||||
<button className="launch-btn" onClick={handleLaunch}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
실행
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button className="updating-btn" disabled>
|
||||
{appStatus ? '미지원' : '확인중'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
{showManual ? (
|
||||
<>
|
||||
{/* 매뉴얼 헤더 바 */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[48px] flex items-center px-4 gap-3 border-b border-[var(--border)] bg-[var(--bg-sidebar)] z-10">
|
||||
<button
|
||||
onClick={() => setShowManual(false)}
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-[13px] font-medium text-[var(--text-secondary)]">사용 매뉴얼</span>
|
||||
</div>
|
||||
<iframe
|
||||
src="appdocs://host/install.html"
|
||||
className="absolute left-0 right-0 bottom-0 border-none"
|
||||
style={{ top: '48px', width: '100%', height: 'calc(100% - 48px)', background: '#0a0b0e' }}
|
||||
/>
|
||||
</>
|
||||
) : appConfig ? (
|
||||
<AppView
|
||||
app={appConfig}
|
||||
isRunning={isRunning}
|
||||
onShowManual={() => setShowManual(true)}
|
||||
onFocusApp={handleFocusApp}
|
||||
onCloseApp={handleCloseApp}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center px-10 text-center">
|
||||
<div>
|
||||
<p className="text-[15px] font-medium text-[var(--text-secondary)]">연결된 앱이 없습니다.</p>
|
||||
<p className="mt-2 text-[12px] text-[var(--text-muted)]">관리 콘솔에서 앱을 연결하면 이 화면에 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showManual && (
|
||||
<div className="launcher-version-wrap">
|
||||
{launcherUpdateAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-update-text"
|
||||
onClick={() => handleUpdate('launcher', launcherStatus.latestVersion, launcherStatus.downloadUrl)}
|
||||
disabled={hasAnyUpdatingApp}
|
||||
>
|
||||
업데이트
|
||||
</button>
|
||||
)}
|
||||
<div className="version-label">v{localVersions.launcher ?? version}</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import TitleBar from '../components/TitleBar'
|
||||
import LoginForm from './LoginForm'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
import PasswordResetForm from './PasswordResetForm'
|
||||
import SignupForm from './SignupForm'
|
||||
|
||||
type View = 'login' | 'signup' | 'password-reset'
|
||||
|
||||
type LoginNotice = {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
linkUrl: string | null
|
||||
}
|
||||
|
||||
type LauncherUpdateStatus = {
|
||||
latestVersion: string | null
|
||||
action: 'INSTALL' | 'UPDATE' | 'LAUNCH' | 'UNAVAILABLE'
|
||||
downloadUrl?: string | null
|
||||
}
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: (session: AuthSession) => void
|
||||
}
|
||||
|
||||
const version = import.meta.env.VITE_APP_VERSION
|
||||
const TYPING_TEXT = '클라우드 기반 자율주행 모빌리티\n소프트웨어 개발, 검증 및 운영 환경.'
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
const DISMISSED_LOGIN_NOTICE_KEY = 'atnmy.login.dismissedNoticeId'
|
||||
|
||||
function readDismissedLoginNoticeId(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(DISMISSED_LOGIN_NOTICE_KEY) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function storeDismissedLoginNoticeId(noticeId: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(DISMISSED_LOGIN_NOTICE_KEY, noticeId)
|
||||
} catch {
|
||||
// Notice dismissal should not break the login screen.
|
||||
}
|
||||
}
|
||||
|
||||
function Login({ onLogin }: LoginProps): React.JSX.Element {
|
||||
const [view, setView] = useState<View>('login')
|
||||
const [noticeDismissed, setNoticeDismissed] = useState(false)
|
||||
const [notice, setNotice] = useState<LoginNotice | null>(null)
|
||||
const [launcherStatus, setLauncherStatus] = useState<LauncherUpdateStatus | null>(null)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateProgress, setUpdateProgress] = useState(0)
|
||||
const [typedCount, setTypedCount] = useState(0)
|
||||
const [loginLoading, setLoginLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/notices/login`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((payload: { notice?: LoginNotice | null } | null) => {
|
||||
if (cancelled) return
|
||||
const nextNotice = payload?.notice ?? null
|
||||
setNotice(nextNotice && readDismissedLoginNoticeId() !== nextNotice.id ? nextNotice : null)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNotice(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const query = new URLSearchParams({
|
||||
currentVersion: version,
|
||||
installed: 'true',
|
||||
})
|
||||
|
||||
fetch(`${LAUNCHER_API_BASE_URL}/api/apps/launcher/update?${query}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((status: LauncherUpdateStatus | null) => {
|
||||
if (!cancelled) setLauncherStatus(status)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLauncherStatus(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (_event: unknown, payload: { appName: string; progress: number }): void => {
|
||||
if (payload.appName === 'launcher') setUpdateProgress(payload.progress)
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on('app-download-progress', handler)
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeListener('app-download-progress', handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLauncherUpdate = async (): Promise<void> => {
|
||||
if (!launcherStatus?.downloadUrl) {
|
||||
alert('다운로드 URL이 없습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
setUpdating(true)
|
||||
setUpdateProgress(0)
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('install-launcher-update', {
|
||||
downloadUrl: launcherStatus.downloadUrl,
|
||||
latestVersion: launcherStatus.latestVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '런처 업데이트에 실패했습니다.'
|
||||
alert(message)
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
setUpdateProgress(0)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let cancelled = false
|
||||
|
||||
const typeNext = (count: number): void => {
|
||||
if (cancelled) return
|
||||
setTypedCount(count)
|
||||
|
||||
if (count >= TYPING_TEXT.length) {
|
||||
timer = setTimeout(() => typeNext(0), 3000)
|
||||
return
|
||||
}
|
||||
|
||||
timer = setTimeout(() => typeNext(count + 1), 55)
|
||||
}
|
||||
|
||||
const start = setTimeout(() => typeNext(0), 900)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(start)
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen overflow-hidden flex flex-col bg-[var(--bg-deep)]">
|
||||
<TitleBar />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{/* 플랫폼 공지 배너 — absolute로 레이아웃 영향 없음 */}
|
||||
{notice && !noticeDismissed && (
|
||||
<div className="absolute top-0 left-0 right-0 z-[100] flex items-center gap-3 px-5 py-[9px] bg-[rgba(22,27,42,0.92)] border-b border-[rgba(59,127,250,0.15)]">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<p className="flex-1 text-[11.5px] text-[var(--text-secondary)] leading-none">
|
||||
<span className="text-[var(--accent)] font-semibold mr-2">[{notice.title}]</span>
|
||||
{notice.body}
|
||||
{notice.linkUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
window.open(notice.linkUrl ?? '', '_blank', 'noopener,noreferrer')
|
||||
}}
|
||||
className="ml-2 text-[var(--accent)] hover:underline cursor-pointer"
|
||||
>
|
||||
자세히 보기
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
storeDismissedLoginNoticeId(notice.id)
|
||||
setNoticeDismissed(true)
|
||||
}}
|
||||
className="shrink-0 flex items-center justify-center w-5 h-5 rounded text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loginLoading && (
|
||||
<div className="absolute inset-0 z-50 bg-[rgba(0,0,0,0.45)] pointer-events-none animate-[fadeIn_0.2s_ease]" />
|
||||
)}
|
||||
<div className="login-version-wrap">
|
||||
{launcherStatus?.action === 'UPDATE' && (
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-update-text"
|
||||
onClick={handleLauncherUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? `업데이트 ${updateProgress}%` : '업데이트'}
|
||||
</button>
|
||||
)}
|
||||
<div className="version-label">v{version}</div>
|
||||
</div>
|
||||
|
||||
{/* Left — 브랜딩 + 동영상 */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
<video className="login-video" src="./video/main_video.mp4" autoPlay loop muted playsInline />
|
||||
<div className="login-video-overlay" />
|
||||
|
||||
<div className="absolute top-[72px] left-[52px] z-[2] animate-reveal">
|
||||
<h1 className="brand-title" style={{ marginBottom: 14, fontSize: 44 }}>
|
||||
Autonomy<br /><span>Studio</span>
|
||||
</h1>
|
||||
<div className="divider" style={{ marginBottom: 16 }} />
|
||||
<p className="description">
|
||||
{(() => {
|
||||
const text = TYPING_TEXT.slice(0, typedCount)
|
||||
const [line1, line2] = text.split('\n')
|
||||
return (
|
||||
<>
|
||||
{line1}
|
||||
{line2 !== undefined && <><br />{line2}</>}
|
||||
<span className="typing-cursor" />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — 슬라이딩 폼 패널 */}
|
||||
<div className="login-panel w-[420px] shrink-0 relative overflow-hidden">
|
||||
<div className="login-panel-edge" />
|
||||
<div className="login-panel-glow" />
|
||||
|
||||
<div
|
||||
className="flex h-full transition-transform duration-[420ms] ease-in-out"
|
||||
style={{
|
||||
width: '1260px',
|
||||
transform: view === 'login'
|
||||
? 'translateX(0)'
|
||||
: view === 'signup'
|
||||
? 'translateX(-420px)'
|
||||
: 'translateX(-840px)'
|
||||
}}
|
||||
>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<LoginForm
|
||||
onLogin={onLogin}
|
||||
onGoSignup={() => setView('signup')}
|
||||
onGoPasswordReset={() => setView('password-reset')}
|
||||
onLoadingChange={setLoginLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<SignupForm onGoLogin={() => setView('login')} onSignupComplete={onLogin} />
|
||||
</div>
|
||||
<div className="w-[420px] flex items-center justify-center">
|
||||
<PasswordResetForm onGoLogin={() => setView('login')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
@@ -0,0 +1,380 @@
|
||||
import { useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { EnvelopeIcon, LockIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
|
||||
interface LoginFormProps {
|
||||
onLogin: (session: AuthSession) => void
|
||||
onGoSignup: () => void
|
||||
onGoPasswordReset: () => void
|
||||
onLoadingChange: (v: boolean) => void
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
accessToken: string
|
||||
accessTokenExpiresIn?: number
|
||||
refreshToken?: string
|
||||
refreshTokenExpiresIn?: number
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
role: string
|
||||
roles?: string[]
|
||||
createdAt?: string
|
||||
lastLoginAt?: string | null
|
||||
primaryAffiliation?: {
|
||||
id: string
|
||||
organizationId: string
|
||||
organizationName: string
|
||||
organizationType: string
|
||||
department: string | null
|
||||
position: string | null
|
||||
} | null
|
||||
currentLicense?: {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
startsAt: string
|
||||
expiresAt: string | null
|
||||
} | null
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_ATNMY_STDO_API_BASE_URL || 'http://localhost:4200').replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const MIN_LOGIN_DURATION_MS = 2000
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
const LAST_LOGIN_EMAIL_KEY = 'atnmy.auth.last-login-email'
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
type TailscaleProvisionResponse = {
|
||||
installTailscale?: boolean
|
||||
tailscaleAuthKey?: string
|
||||
tailscaleLoginServer?: string
|
||||
tailscaleHostname?: string
|
||||
}
|
||||
|
||||
type PendingTailscaleConfirm = {
|
||||
session: AuthSession
|
||||
email: string
|
||||
keepLogin: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
function extractProvisionErrorMessage(payload: unknown): string {
|
||||
if (!payload) return ''
|
||||
if (typeof payload === 'string') return payload
|
||||
if (Array.isArray(payload)) return payload.map(extractProvisionErrorMessage).filter(Boolean).join(', ')
|
||||
if (typeof payload !== 'object') return ''
|
||||
|
||||
const record = payload as Record<string, unknown>
|
||||
const candidates = [record.message, record.details, record.error]
|
||||
for (const candidate of candidates) {
|
||||
const message = extractProvisionErrorMessage(candidate)
|
||||
if (message && message !== '[object Object]' && !/^(bad gateway|internal server error|unauthorized|forbidden)$/i.test(message)) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHeadscaleAccount(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/@/g, '-at-')
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
function isSameTailscaleAccount(statusAccount: string | undefined, loginAccount: string): boolean {
|
||||
if (!statusAccount) return false
|
||||
return normalizeHeadscaleAccount(statusAccount) === normalizeHeadscaleAccount(loginAccount)
|
||||
}
|
||||
|
||||
function readLastLoginEmail(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_LOGIN_EMAIL_KEY) ?? ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function storeLastLoginEmail(email: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email)
|
||||
} catch {
|
||||
// Login should not fail if local storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLauncherTailscale(account: string, password: string): Promise<void> {
|
||||
if (!window.launcherTailscale) return
|
||||
const sshKey = await window.launcherSshKey?.ensure().catch(() => null)
|
||||
|
||||
const status = await window.launcherTailscale.status().catch(() => null)
|
||||
const isSameAccount = Boolean(
|
||||
status &&
|
||||
(
|
||||
isSameTailscaleAccount(status.loginName, account) ||
|
||||
isSameTailscaleAccount(status.userName, account)
|
||||
)
|
||||
)
|
||||
if (
|
||||
status?.connected &&
|
||||
isSameAccount
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
status?.backendState === 'Unavailable' &&
|
||||
/command not found|enoent|no such file|명령을 찾을 수 없습니다/i.test(status.error ?? '')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/provision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
hostname: window.navigator.userAgent.includes('Electron') ? 'launcher-client' : 'client',
|
||||
usageType: 'launcher-client',
|
||||
publicSshKey: sshKey?.publicKey,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as unknown
|
||||
const message = extractProvisionErrorMessage(payload)
|
||||
throw new Error(message ? `Tailscale 연결 정보를 가져오지 못했습니다: ${message}` : 'Tailscale 연결 정보를 가져오지 못했습니다.')
|
||||
}
|
||||
|
||||
const provision = await response.json() as TailscaleProvisionResponse
|
||||
if (!provision.tailscaleAuthKey) {
|
||||
throw new Error('Tailscale 인증키가 없습니다.')
|
||||
}
|
||||
|
||||
const result = await window.launcherTailscale.login(
|
||||
provision.tailscaleAuthKey,
|
||||
provision.tailscaleLoginServer ?? '',
|
||||
provision.tailscaleHostname,
|
||||
Boolean(status && status.backendState !== 'Unavailable' && !isSameAccount),
|
||||
)
|
||||
if (!result.ok) {
|
||||
const nextStatus = await window.launcherTailscale.status().catch(() => null)
|
||||
if (nextStatus?.connected) return
|
||||
throw new Error(result.message || 'Tailscale 연결에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function LoginForm({ onLogin, onGoSignup, onGoPasswordReset, onLoadingChange }: LoginFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState(() => readLastLoginEmail())
|
||||
const [password, setPassword] = useState('qwer1234')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [keepLogin, setKeepLogin] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [pendingTailscaleConfirm, setPendingTailscaleConfirm] = useState<PendingTailscaleConfirm | null>(null)
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
|
||||
const completeLogin = async (session: AuthSession, loginEmail: string, persistLogin: boolean, startedAt: number): Promise<void> => {
|
||||
storeLastLoginEmail(loginEmail.trim())
|
||||
if (persistLogin) {
|
||||
window.localStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
window.sessionStorage.removeItem(AUTH_SESSION_KEY)
|
||||
} else {
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
}
|
||||
const elapsed = Date.now() - startedAt
|
||||
if (elapsed < MIN_LOGIN_DURATION_MS) {
|
||||
await delay(MIN_LOGIN_DURATION_MS - elapsed)
|
||||
}
|
||||
onLogin(session)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
if (emailError) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setPendingTailscaleConfirm(null)
|
||||
onLoadingChange(true)
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
const response = await fetch(`${LAUNCHER_API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('이메일 또는 비밀번호를 확인하세요.')
|
||||
}
|
||||
const session = await response.json() as AuthSession
|
||||
try {
|
||||
await ensureLauncherTailscale(email, password)
|
||||
} catch (tailscaleError) {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
setPendingTailscaleConfirm({
|
||||
session,
|
||||
email,
|
||||
keepLogin,
|
||||
message: tailscaleError instanceof Error ? tailscaleError.message : 'Tailscale 연결에 실패했습니다.'
|
||||
})
|
||||
return
|
||||
}
|
||||
await completeLogin(session, email, keepLogin, startedAt)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
const continueWithoutTailscale = async (): Promise<void> => {
|
||||
if (!pendingTailscaleConfirm) return
|
||||
const pending = pendingTailscaleConfirm
|
||||
setPendingTailscaleConfirm(null)
|
||||
setLoading(true)
|
||||
setError('')
|
||||
onLoadingChange(true)
|
||||
try {
|
||||
await completeLogin(pending.session, pending.email, pending.keepLogin, Date.now())
|
||||
} catch (continueError) {
|
||||
setError(continueError instanceof Error ? continueError.message : '로그인에 실패했습니다.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
onLoadingChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1] animate-reveal-late">
|
||||
|
||||
<h2 className="text-[20px] font-bold text-[var(--text-primary)] mb-1">로그인</h2>
|
||||
<p className="text-[12px] text-[var(--text-muted)] mb-6">계정에 로그인하여 서비스를 이용하세요.</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
<div className="login-input-wrap">
|
||||
<EnvelopeIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="email"
|
||||
placeholder="이메일"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||
</div>
|
||||
<div className="login-input-wrap">
|
||||
<LockIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="비밀번호"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<EyeToggle show={showPassword} onClick={() => setShowPassword((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepLogin}
|
||||
onChange={(e) => setKeepLogin(e.target.checked)}
|
||||
className="login-checkbox"
|
||||
/>
|
||||
<span className="text-[12px] text-[var(--text-secondary)]">로그인 상태 유지</span>
|
||||
</label>
|
||||
<button type="button" onClick={onGoPasswordReset} className="text-[12px] text-[var(--accent)] hover:text-[#6aa0ff] hover:underline transition-colors cursor-pointer">
|
||||
비밀번호 찾기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex h-5 items-center justify-center">
|
||||
<p className={`text-center text-[12px] text-red-300 transition-opacity ${error ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{error || 'placeholder'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="login-btn w-full mb-4" disabled={loading || Boolean(emailError)} style={{ opacity: loading || emailError ? 0.7 : 1 }}>
|
||||
{loading ? '로그인 중...' : '로그인'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-google-btn w-full mb-6">
|
||||
<GoogleIcon />
|
||||
Google로 로그인
|
||||
</button>
|
||||
|
||||
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||
계정이 없으신가요?{' '}
|
||||
<button type="button" onClick={onGoSignup} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||
회원가입
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
{pendingTailscaleConfirm && createPortal(
|
||||
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/45 px-6 backdrop-blur-[2px]">
|
||||
<section className="w-full max-w-[380px] overflow-hidden rounded-xl border border-white/14 bg-[#121722] shadow-[0_28px_80px_rgba(0,0,0,0.45)]">
|
||||
<div className="border-b border-white/10 px-5 py-4">
|
||||
<h3 className="text-[15px] font-bold text-white">Tailscale 연결 실패</h3>
|
||||
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/62">
|
||||
플랫폼 원격 제어 연결을 설정하지 못했습니다. 로그인은 계속 진행할 수 있지만, 원격 플랫폼 연결 기능은 제한될 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="max-h-[96px] overflow-y-auto rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-[11px] font-medium leading-relaxed text-white/56">
|
||||
{pendingTailscaleConfirm.message}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingTailscaleConfirm(null)}
|
||||
className="h-9 cursor-pointer rounded-md border border-white/16 bg-white/6 px-4 text-[12px] font-bold text-white/72 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continueWithoutTailscale()}
|
||||
className="h-9 cursor-pointer rounded-md border border-[#5588ff] bg-[#5588ff] px-4 text-[12px] font-bold text-white transition hover:bg-[#4778ea]"
|
||||
>
|
||||
계속 진행
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginForm
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { EnvelopeIcon, EyeToggle, LockIcon } from '../components/AuthIcons'
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
onGoLogin: () => void
|
||||
}
|
||||
|
||||
const AUTH_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||
).replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function PasswordResetForm({ onGoLogin }: PasswordResetFormProps): React.JSX.Element {
|
||||
const [email, setEmail] = useState('')
|
||||
const [verificationCode, setVerificationCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [codeTimer, setCodeTimer] = useState(0)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const emailError = email.length > 0 && !EMAIL_PATTERN.test(email) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (codeTimer <= 0) return
|
||||
const timer = setTimeout(() => setCodeTimer((value) => value - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [codeTimer])
|
||||
|
||||
const handleSendCode = async (): Promise<void> => {
|
||||
if (emailError) return
|
||||
setSendingCode(true)
|
||||
setVerificationCode('')
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('가입되지 않은 이메일입니다.')
|
||||
}
|
||||
if (!response.ok) throw new Error('인증코드 발송에 실패했습니다.')
|
||||
|
||||
setCodeSent(true)
|
||||
setCodeTimer(60)
|
||||
} catch (sendError) {
|
||||
setCodeSent(false)
|
||||
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setSuccess('')
|
||||
|
||||
if (emailError) {
|
||||
setError('올바른 이메일 형식으로 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (!/^\d{6}$/.test(verificationCode)) {
|
||||
setError('이메일 인증코드 6자리를 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
if (password !== passwordConfirm) {
|
||||
setError('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/password-reset/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
verificationCode,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.status === 400) {
|
||||
throw new Error('이메일 또는 인증코드를 확인해주세요.')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('비밀번호 변경에 실패했습니다.')
|
||||
}
|
||||
|
||||
setSuccess('비밀번호가 변경되었습니다. 새 비밀번호로 로그인하세요.')
|
||||
setPassword('')
|
||||
setPasswordConfirm('')
|
||||
setVerificationCode('')
|
||||
setCodeSent(false)
|
||||
setCodeTimer(0)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '비밀번호 변경에 실패했습니다.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoLogin}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">비밀번호 찾기</h2>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--text-muted)] mb-5">이메일 인증 후 새 비밀번호를 설정하세요.</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div>
|
||||
<div className="login-input-wrap">
|
||||
<EnvelopeIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="email"
|
||||
placeholder="이메일을 입력해주세요."
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setCodeSent(false)
|
||||
setSuccess('')
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{ maxHeight: email.length > 0 ? (codeSent ? '238px' : '46px') : '0', opacity: email.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex gap-2">
|
||||
<div className="login-input-wrap flex-1">
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
|
||||
</svg>
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="text"
|
||||
placeholder="인증코드 6자리"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
required={codeSent}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || codeTimer > 0 || !email || Boolean(emailError)}
|
||||
onClick={handleSendCode}
|
||||
className="verify-code-btn"
|
||||
>
|
||||
{sendingCode
|
||||
? '발송 중...'
|
||||
: codeTimer > 0
|
||||
? `재발송 (${codeTimer}s)`
|
||||
: codeSent ? '재발송' : '인증코드 발송'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
|
||||
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
|
||||
>
|
||||
인증코드가 이메일로 발송되었습니다.
|
||||
</div>
|
||||
|
||||
<div className="login-input-wrap">
|
||||
<LockIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="새 비밀번호"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required={codeSent}
|
||||
/>
|
||||
<EyeToggle show={showPassword} onClick={() => setShowPassword((value) => !value)} />
|
||||
</div>
|
||||
<div className="login-input-wrap">
|
||||
<LockIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type={showPasswordConfirm ? 'text' : 'password'}
|
||||
placeholder="새 비밀번호 확인"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
required={codeSent}
|
||||
/>
|
||||
<EyeToggle show={showPasswordConfirm} onClick={() => setShowPasswordConfirm((value) => !value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && <p className="mb-4 text-center text-[12px] text-[rgba(59,200,130,0.9)]">{success}</p>}
|
||||
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
|
||||
|
||||
<button type="submit" className="login-btn w-full mb-5" disabled={submitting || !codeSent} style={{ opacity: submitting || !codeSent ? 0.7 : 1 }}>
|
||||
{submitting ? '변경 중...' : '비밀번호 변경'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||
비밀번호가 기억나셨나요?{' '}
|
||||
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||
로그인
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordResetForm
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EnvelopeIcon, LockIcon, PersonIcon, EyeToggle, GoogleIcon } from '../components/AuthIcons'
|
||||
import type { AuthSession } from './LoginForm'
|
||||
|
||||
interface SignupFormProps {
|
||||
onGoLogin: () => void
|
||||
onSignupComplete: (session: AuthSession) => void
|
||||
}
|
||||
|
||||
const AUTH_BASE_URL = (
|
||||
import.meta.env.VITE_ATNMY_AUTH_API_BASE_URL || 'http://localhost:4100'
|
||||
).replace(/\/+$/, '')
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const AUTH_SESSION_KEY = 'atnmy.auth.session'
|
||||
|
||||
function SignupForm({ onGoLogin, onSignupComplete }: SignupFormProps): React.JSX.Element {
|
||||
const [name, setName] = useState('')
|
||||
const [signupEmail, setSignupEmail] = useState('')
|
||||
const [signupPassword, setSignupPassword] = useState('')
|
||||
const [signupConfirm, setSignupConfirm] = useState('')
|
||||
const [showSignupPw, setShowSignupPw] = useState(false)
|
||||
const [showSignupConfirm, setShowSignupConfirm] = useState(false)
|
||||
const [agreeTerms, setAgreeTerms] = useState(false)
|
||||
const [agreePrivacy, setAgreePrivacy] = useState(false)
|
||||
const [verifyCode, setVerifyCode] = useState('')
|
||||
const [codeSent, setCodeSent] = useState(false)
|
||||
const [verificationEmail, setVerificationEmail] = useState('')
|
||||
const [sendingCode, setSendingCode] = useState(false)
|
||||
const [codeTimer, setCodeTimer] = useState(0)
|
||||
const [promoCode, setPromoCode] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const emailError = signupEmail.length > 0 && !EMAIL_PATTERN.test(signupEmail) ? '올바른 이메일 형식으로 입력해주세요.' : ''
|
||||
const normalizedSignupEmail = signupEmail.trim().toLowerCase()
|
||||
|
||||
useEffect(() => {
|
||||
if (codeTimer <= 0) return
|
||||
const t = setTimeout(() => setCodeTimer((v) => v - 1), 1000)
|
||||
return () => clearTimeout(t)
|
||||
}, [codeTimer])
|
||||
|
||||
const handleSendCode = async (): Promise<void> => {
|
||||
if (emailError) return
|
||||
setSendingCode(true)
|
||||
setVerifyCode('')
|
||||
setVerificationEmail('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/email-verification/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email: signupEmail }),
|
||||
})
|
||||
|
||||
if (response.status === 409) {
|
||||
throw new Error('이미 가입된 이메일입니다.')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('인증코드 발송에 실패했습니다.')
|
||||
}
|
||||
|
||||
setCodeSent(true)
|
||||
setVerificationEmail(normalizedSignupEmail)
|
||||
setCodeTimer(60)
|
||||
} catch (sendError) {
|
||||
setCodeSent(false)
|
||||
setError(sendError instanceof Error ? sendError.message : '인증코드 발송에 실패했습니다.')
|
||||
} finally {
|
||||
setSendingCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (emailError) {
|
||||
setError('올바른 이메일 형식으로 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (signupPassword.length < 8) {
|
||||
setError('비밀번호는 8자 이상이어야 합니다.')
|
||||
return
|
||||
}
|
||||
if (signupPassword !== signupConfirm) {
|
||||
setError('비밀번호가 일치하지 않습니다.')
|
||||
return
|
||||
}
|
||||
if (!agreeTerms || !agreePrivacy) {
|
||||
setError('필수 약관에 동의해주세요.')
|
||||
return
|
||||
}
|
||||
if (!/^\d{6}$/.test(verifyCode)) {
|
||||
setError('이메일 인증코드 6자리를 입력해주세요.')
|
||||
return
|
||||
}
|
||||
if (!codeSent || verificationEmail !== normalizedSignupEmail) {
|
||||
setError('현재 이메일로 인증코드를 다시 발송해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await fetch(`${AUTH_BASE_URL}/auth/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
email: signupEmail,
|
||||
password: signupPassword,
|
||||
verificationCode: verifyCode,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.status === 409) {
|
||||
throw new Error('이미 가입된 이메일입니다.')
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new Error('인증코드가 올바르지 않거나 만료되었습니다.')
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('회원가입에 실패했습니다.')
|
||||
}
|
||||
|
||||
const session = await response.json() as AuthSession
|
||||
window.localStorage.removeItem(AUTH_SESSION_KEY)
|
||||
window.sessionStorage.setItem(AUTH_SESSION_KEY, JSON.stringify(session))
|
||||
onSignupComplete(session)
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : '회원가입에 실패했습니다.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="w-[300px] relative z-[1]">
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onGoLogin}
|
||||
className="flex items-center justify-center w-6 h-6 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[rgba(255,255,255,0.06)] transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 18l-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="text-[20px] font-bold text-[var(--text-primary)]">회원가입</h2>
|
||||
</div>
|
||||
<p className="text-[12px] text-[var(--text-muted)] mb-5">새 계정을 만들어 서비스를 이용해보세요.</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div className="login-input-wrap">
|
||||
<PersonIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="text"
|
||||
placeholder="이름을 입력해주세요."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="login-input-wrap">
|
||||
<EnvelopeIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="email"
|
||||
placeholder="이메일을 입력해주세요."
|
||||
value={signupEmail}
|
||||
onChange={(e) => {
|
||||
const nextEmail = e.target.value
|
||||
const nextNormalizedEmail = nextEmail.trim().toLowerCase()
|
||||
setSignupEmail(nextEmail)
|
||||
if (verificationEmail && nextNormalizedEmail !== verificationEmail) {
|
||||
setCodeSent(false)
|
||||
setVerifyCode('')
|
||||
setCodeTimer(0)
|
||||
}
|
||||
}}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{emailError && <p className="mt-1 text-[11px] text-red-300">{emailError}</p>}
|
||||
</div>
|
||||
|
||||
{/* 인증코드 — 이메일 입력 시 노출 */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-300 ease-in-out"
|
||||
style={{ maxHeight: signupEmail.length > 0 ? (codeSent ? '78px' : '46px') : '0', opacity: signupEmail.length > 0 ? 1 : 0 }}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="login-input-wrap flex-1">
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
|
||||
</svg>
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="text"
|
||||
placeholder="인증코드 6자리"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || codeTimer > 0 || !signupEmail || Boolean(emailError)}
|
||||
onClick={handleSendCode}
|
||||
className="verify-code-btn"
|
||||
>
|
||||
{sendingCode
|
||||
? '발송 중...'
|
||||
: codeTimer > 0
|
||||
? `재발송 (${codeTimer}s)`
|
||||
: codeSent ? '재발송' : '인증코드 발송'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 발송 완료 안내 */}
|
||||
<div
|
||||
className="transition-all duration-300 overflow-hidden text-[11px] leading-none"
|
||||
style={{ maxHeight: codeSent ? '20px' : '0', opacity: codeSent ? 1 : 0, color: 'rgba(59,200,130,0.85)' }}
|
||||
>
|
||||
인증코드가 이메일로 발송되었습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-input-wrap">
|
||||
<LockIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type={showSignupPw ? 'text' : 'password'}
|
||||
placeholder="비밀번호를 입력해주세요."
|
||||
value={signupPassword}
|
||||
onChange={(e) => setSignupPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<EyeToggle show={showSignupPw} onClick={() => setShowSignupPw((v) => !v)} />
|
||||
</div>
|
||||
<div className="login-input-wrap">
|
||||
<LockIcon />
|
||||
<input
|
||||
className="login-input-field"
|
||||
type={showSignupConfirm ? 'text' : 'password'}
|
||||
placeholder="비밀번호를 다시 입력해주세요."
|
||||
value={signupConfirm}
|
||||
onChange={(e) => setSignupConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<EyeToggle show={showSignupConfirm} onClick={() => setShowSignupConfirm((v) => !v)} />
|
||||
</div>
|
||||
|
||||
{/* 프로모션 코드 (선택) */}
|
||||
<div className="login-input-wrap">
|
||||
<svg className="login-input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
|
||||
<line x1="7" y1="7" x2="7.01" y2="7"/>
|
||||
</svg>
|
||||
<input
|
||||
className="login-input-field"
|
||||
type="text"
|
||||
placeholder="프로모션 코드 (선택)"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 약관 동의 */}
|
||||
<div className="flex flex-col gap-[10px] mb-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={agreeTerms} onChange={(e) => setAgreeTerms(e.target.checked)} className="login-checkbox" required />
|
||||
<span className="text-[12px] text-[var(--text-secondary)]">
|
||||
이용약관에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={agreePrivacy} onChange={(e) => setAgreePrivacy(e.target.checked)} className="login-checkbox" required />
|
||||
<span className="text-[12px] text-[var(--text-secondary)]">
|
||||
개인정보 처리방침에 동의합니다. <span className="text-[var(--text-muted)]">(필수)</span>
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" className="text-[11px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors ml-2 shrink-0">보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-4 text-center text-[12px] text-red-300">{error}</p>}
|
||||
|
||||
<button type="submit" className="login-btn w-full mb-4" disabled={submitting} style={{ opacity: submitting ? 0.7 : 1 }}>
|
||||
{submitting ? '가입 중...' : '회원가입'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
<span className="text-[11px] text-[var(--text-muted)]">또는</span>
|
||||
<div className="flex-1 h-px bg-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
<button type="button" className="login-google-btn w-full mb-5">
|
||||
<GoogleIcon />
|
||||
Google로 회원가입
|
||||
</button>
|
||||
|
||||
<p className="text-center text-[12px] text-[var(--text-muted)]">
|
||||
이미 계정이 있나요?{' '}
|
||||
<button type="button" onClick={onGoLogin} className="text-[var(--accent)] hover:text-[#6aa0ff] hover:underline cursor-pointer transition-colors">
|
||||
로그인
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignupForm
|
||||
Reference in New Issue
Block a user