공개: KoTalk 최신 기준선

This commit is contained in:
Ian 2026-04-16 09:24:26 +09:00
commit debf62f76e
572 changed files with 41689 additions and 0 deletions

25
src/PhysOn.Web/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.tsbuildinfo
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

27
src/PhysOn.Web/README.md Normal file
View file

@ -0,0 +1,27 @@
# vstalk Web
`src/PhysOn.Web``vstalk.phy.kr`에 배포할 모바일 퍼스트 웹앱 채널입니다.
현재 범위:
- 이름 + 초대코드 기반 초간단 가입
- 최근 대화 목록
- 대화 진입과 메시지 읽기
- 텍스트 메시지 전송
- 모바일 브라우저용 PWA 메타데이터
개발 명령:
```bash
npm install
npm run dev
```
기본 개발 프록시:
- 웹앱: `http://127.0.0.1:4173`
- API 프록시 대상: `http://127.0.0.1:5082`
환경 변수:
- `VITE_API_BASE_URL`
- `VITE_DEV_PROXY_TARGET`
프로덕션 배포는 루트 문서의 [deploy/README.md](../../deploy/README.md)와 [문서/17-vstalk-webapp-mvp-and-rollout-plan.md](../../문서/17-vstalk-webapp-mvp-and-rollout-plan.md)를 따른다.

View file

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

23
src/PhysOn.Web/index.html Normal file
View file

@ -0,0 +1,23 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#101826" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="KoTalk" />
<meta
name="description"
content="업무 대화와 일상 대화를 같은 흐름 안에서 가볍게 이어 주는 KoTalk 웹앱입니다."
/>
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>KoTalk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3811
src/PhysOn.Web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "physon-web",
"private": true,
"version": "0.1.0-alpha.2",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"puppeteer-core": "^24.41.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
}
}

View file

@ -0,0 +1,5 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="180" height="180" rx="50" fill="#101826"/>
<path d="M42 55C42 47.268 48.268 41 56 41H124C131.732 41 138 47.268 138 55V104C138 111.732 131.732 118 124 118H89.285L65.86 136.409C61.252 140.031 54.55 136.752 54.55 130.896V118H56C48.268 118 42 111.732 42 104V55Z" fill="#F6F2EA"/>
<path d="M63 77.5L78.363 100.438L92.545 77.727L107.223 100.438L122.5 77.5" stroke="#101826" stroke-width="8.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View file

@ -0,0 +1,5 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="144" fill="#101826"/>
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
<path d="M171 201.5L214.894 267L255.4 202.15L297.35 267L341 201.5" stroke="#101826" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 570 B

View file

@ -0,0 +1,24 @@
{
"name": "PhysOn",
"short_name": "PhysOn",
"description": "업무 소통과 친근한 대화를 한 손 흐름으로 이어 주는 모바일 중심 메신저 웹앱",
"start_url": "/",
"display": "standalone",
"background_color": "#101826",
"theme_color": "#101826",
"lang": "ko-KR",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/mask-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

View file

@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="144" fill="#101826"/>
<path d="M120 150C120 127.909 137.909 110 160 110H352C374.091 110 392 127.909 392 150V304C392 326.091 374.091 344 352 344H251.232L184.314 396.674C171.148 407.041 152 397.656 152 380.899V344H160C137.909 344 120 326.091 120 304V150Z" fill="#F6F2EA"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View file

@ -0,0 +1,57 @@
const CACHE_NAME = 'vs-talk-shell-v2';
const SHELL = ['/manifest.webmanifest', '/icon.svg', '/apple-touch-icon.svg', '/mask-icon.svg'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))),
),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/v1/')) {
return;
}
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request)
.then(async (response) => {
const cache = await caches.open(CACHE_NAME);
cache.put('/index.html', response.clone());
return response;
})
.catch(async () => {
const cache = await caches.open(CACHE_NAME);
return (await cache.match('/index.html')) ?? Response.error();
}),
);
return;
}
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
const cloned = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, cloned));
return response;
});
}),
);
});

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
<rect width="128" height="128" rx="34" fill="#17372F"/>
<path d="M27 33h23l14 25 14-25h23L75 95H53L27 33Z" fill="#F6E8D5"/>
<path d="M60 39h8v50h-8z" fill="#DDB07B"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

1093
src/PhysOn.Web/src/App.css Normal file

File diff suppressed because it is too large Load diff

1573
src/PhysOn.Web/src/App.tsx Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,71 @@
:root {
font-family:
'Pretendard Variable',
'SUIT Variable',
'Noto Sans KR',
'Apple SD Gothic Neo',
'Segoe UI',
sans-serif;
line-height: 1.5;
font-weight: 500;
color: #111111;
background: #f5f5f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--surface-page: #f5f5f6;
--surface-base: #ffffff;
--surface-raised: #fcfcfc;
--surface-muted: #f6f6f7;
--surface-selected: #eef2f6;
--surface-chat-mine: #f1f1f2;
--border-subtle: #ececef;
--border-strong: #d8d8dd;
--border-contrast: #18181b;
--text-strong: #141416;
--text-soft: #333338;
--text-muted: #7b7b84;
--focus-ring: #1a73e8;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--surface-page);
overflow-x: hidden;
}
body {
margin: 0;
min-width: 320px;
min-height: 100svh;
background: var(--surface-page);
overflow-x: hidden;
}
button,
input,
textarea {
font: inherit;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
#root {
min-height: 100svh;
width: 100%;
}
a {
color: inherit;
text-decoration: none;
}

View file

@ -0,0 +1,202 @@
import type {
ApiEnvelope,
ApiErrorEnvelope,
BootstrapResponse,
ListEnvelope,
MessageItemDto,
ReadCursorUpdatedDto,
RefreshTokenRequest,
RefreshTokenResponse,
RegisterAlphaQuickRequest,
RegisterAlphaQuickResponse,
UpdateReadCursorRequest,
} from '../types'
export class ApiRequestError extends Error {
readonly code?: string
readonly status?: number
constructor(message: string, code?: string, status?: number) {
super(message)
this.name = 'ApiRequestError'
this.code = code
this.status = status
}
}
function resolveErrorMessage(status: number, code?: string, fallback?: string): string {
if (status === 401) {
return '연결이 잠시 만료되었습니다. 다시 이어서 들어와 주세요.'
}
if (status === 403) {
return '이 화면은 아직 사용할 수 없습니다. 초대 상태를 확인해 주세요.'
}
if (status === 404) {
return '대화 정보를 다시 불러오는 중입니다. 잠시 후 다시 시도해 주세요.'
}
if (status === 429) {
return '요청이 많습니다. 잠시 후 다시 시도해 주세요.'
}
if (status >= 500) {
return '지금은 연결이 고르지 않습니다. 잠시 후 다시 시도해 주세요.'
}
if (code === 'invite_code_invalid') {
return '초대코드를 다시 확인해 주세요.'
}
return fallback ?? '요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.'
}
function ensureTrailingSlash(value: string): string {
if (!value) {
return `${window.location.origin}/`
}
return value.endsWith('/') ? value : `${value}/`
}
function buildUrl(apiBaseUrl: string, path: string): string {
return new URL(path.replace(/^\//, ''), ensureTrailingSlash(apiBaseUrl)).toString()
}
function parsePayload<T>(text: string): ApiEnvelope<T> | ApiErrorEnvelope | null {
if (!text) {
return null
}
try {
return JSON.parse(text) as ApiEnvelope<T> | ApiErrorEnvelope
} catch {
return null
}
}
async function request<T>(
apiBaseUrl: string,
path: string,
init: RequestInit,
accessToken?: string,
): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
let response: Response
try {
response = await fetch(buildUrl(apiBaseUrl, path), {
...init,
headers,
})
} catch {
throw new ApiRequestError('네트워크 연결을 확인한 뒤 다시 시도해 주세요.')
}
const text = await response.text()
const payload = parsePayload<T>(text)
if (!response.ok) {
const error = (payload as ApiErrorEnvelope | null)?.error
throw new ApiRequestError(
resolveErrorMessage(response.status, error?.code, error?.message ?? undefined),
error?.code,
response.status,
)
}
if (!payload || !('data' in payload)) {
throw new ApiRequestError('응답을 다시 확인하는 중입니다. 잠시 후 다시 시도해 주세요.')
}
return payload.data
}
export function registerAlphaQuick(
apiBaseUrl: string,
body: RegisterAlphaQuickRequest,
): Promise<RegisterAlphaQuickResponse> {
return request<RegisterAlphaQuickResponse>(apiBaseUrl, '/v1/auth/register/alpha-quick', {
method: 'POST',
body: JSON.stringify(body),
})
}
export function refreshToken(
apiBaseUrl: string,
body: RefreshTokenRequest,
): Promise<RefreshTokenResponse> {
return request<RefreshTokenResponse>(apiBaseUrl, '/v1/auth/token/refresh', {
method: 'POST',
body: JSON.stringify(body),
})
}
export function getBootstrap(apiBaseUrl: string, accessToken: string): Promise<BootstrapResponse> {
return request<BootstrapResponse>(apiBaseUrl, '/v1/bootstrap', { method: 'GET' }, accessToken)
}
export function getMessages(
apiBaseUrl: string,
accessToken: string,
conversationId: string,
beforeSequence?: number,
): Promise<ListEnvelope<MessageItemDto>> {
const query = new URLSearchParams()
if (beforeSequence) {
query.set('beforeSequence', String(beforeSequence))
}
query.set('limit', '60')
const suffix = query.toString()
return request<ListEnvelope<MessageItemDto>>(
apiBaseUrl,
`/v1/conversations/${conversationId}/messages${suffix ? `?${suffix}` : ''}`,
{ method: 'GET' },
accessToken,
)
}
export function sendTextMessage(
apiBaseUrl: string,
accessToken: string,
conversationId: string,
body: { clientRequestId: string; body: string },
): Promise<MessageItemDto> {
return request<MessageItemDto>(
apiBaseUrl,
`/v1/conversations/${conversationId}/messages`,
{
method: 'POST',
body: JSON.stringify(body),
},
accessToken,
)
}
export function updateReadCursor(
apiBaseUrl: string,
accessToken: string,
conversationId: string,
body: UpdateReadCursorRequest,
): Promise<ReadCursorUpdatedDto> {
return request<ReadCursorUpdatedDto>(
apiBaseUrl,
`/v1/conversations/${conversationId}/read-cursor`,
{
method: 'POST',
body: JSON.stringify(body),
},
accessToken,
)
}

View file

@ -0,0 +1,48 @@
import type {
MessageItemDto,
ReadCursorUpdatedDto,
RealtimeEventEnvelope,
SessionConnectedDto,
} from '../types'
export type RealtimeEvent =
| { kind: 'session.connected'; payload: SessionConnectedDto }
| { kind: 'message.created'; payload: MessageItemDto }
| { kind: 'read_cursor.updated'; payload: ReadCursorUpdatedDto }
| { kind: 'unknown'; payload: unknown }
export function buildBrowserWsUrl(rawUrl: string, ticket: string): string {
const url = new URL(rawUrl)
if (window.location.protocol === 'https:' && url.protocol === 'ws:') {
url.protocol = 'wss:'
}
if (window.location.protocol === 'http:' && url.protocol === 'wss:') {
url.protocol = 'ws:'
}
url.searchParams.set('access_token', ticket)
return url.toString()
}
export function parseRealtimeEvent(message: string): RealtimeEvent | null {
let envelope: RealtimeEventEnvelope
try {
envelope = JSON.parse(message) as RealtimeEventEnvelope
} catch {
return null
}
if (!envelope.event) {
return null
}
switch (envelope.event) {
case 'session.connected':
return { kind: envelope.event, payload: envelope.data as SessionConnectedDto }
case 'message.created':
return { kind: envelope.event, payload: envelope.data as MessageItemDto }
case 'read_cursor.updated':
return { kind: envelope.event, payload: envelope.data as ReadCursorUpdatedDto }
default:
return { kind: 'unknown', payload: envelope.data }
}
}

View file

@ -0,0 +1,98 @@
import type { StoredSession } from '../types'
const SESSION_KEY = 'vs-talk.session'
const INSTALL_ID_KEY = 'vs-talk.install-id'
const INVITE_CODE_KEY = 'vs-talk.invite-code'
const DRAFTS_KEY = 'vs-talk.drafts'
function fallbackRandomId(): string {
return `web-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
}
export function getInstallId(): string {
const existing = window.localStorage.getItem(INSTALL_ID_KEY)
if (existing) {
return existing
}
const next = typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : fallbackRandomId()
window.localStorage.setItem(INSTALL_ID_KEY, next)
return next
}
export function readStoredSession(): StoredSession | null {
const raw = window.sessionStorage.getItem(SESSION_KEY) ?? window.localStorage.getItem(SESSION_KEY)
if (!raw) {
return null
}
try {
const parsed = JSON.parse(raw) as StoredSession
if (window.localStorage.getItem(SESSION_KEY)) {
window.localStorage.removeItem(SESSION_KEY)
window.sessionStorage.setItem(SESSION_KEY, raw)
}
return parsed
} catch {
window.sessionStorage.removeItem(SESSION_KEY)
window.localStorage.removeItem(SESSION_KEY)
return null
}
}
export function writeStoredSession(session: StoredSession): void {
window.sessionStorage.setItem(SESSION_KEY, JSON.stringify(session))
window.localStorage.removeItem(SESSION_KEY)
}
export function clearStoredSession(): void {
window.sessionStorage.removeItem(SESSION_KEY)
window.localStorage.removeItem(SESSION_KEY)
}
export function readSavedInviteCode(): string {
return window.localStorage.getItem(INVITE_CODE_KEY) ?? ''
}
export function writeSavedInviteCode(value: string): void {
window.localStorage.setItem(INVITE_CODE_KEY, value)
}
function readDraftMap(): Record<string, string> {
const raw = window.localStorage.getItem(DRAFTS_KEY)
if (!raw) {
return {}
}
try {
return JSON.parse(raw) as Record<string, string>
} catch {
window.localStorage.removeItem(DRAFTS_KEY)
return {}
}
}
export function readConversationDraft(conversationId: string): string {
return readDraftMap()[conversationId] ?? ''
}
export function writeConversationDraft(conversationId: string, value: string): void {
const next = readDraftMap()
if (value.trim()) {
next[conversationId] = value
} else {
delete next[conversationId]
}
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
}
export function clearConversationDraft(conversationId: string): void {
const next = readDraftMap()
delete next[conversationId]
window.localStorage.setItem(DRAFTS_KEY, JSON.stringify(next))
}
export function clearConversationDrafts(): void {
window.localStorage.removeItem(DRAFTS_KEY)
}

View file

@ -0,0 +1,26 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
const isLocalPreview = ['127.0.0.1', 'localhost'].includes(window.location.hostname)
if ('serviceWorker' in navigator && isLocalPreview) {
navigator.serviceWorker.getRegistrations().then((registrations) => {
registrations.forEach((registration) => {
registration.unregister().catch(() => undefined)
})
})
}
if ('serviceWorker' in navigator && !isLocalPreview) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => undefined)
})
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

156
src/PhysOn.Web/src/types.ts Normal file
View file

@ -0,0 +1,156 @@
export interface ApiEnvelope<T> {
data: T
}
export interface ApiError {
code: string
message: string
retryable?: boolean
fieldErrors?: Record<string, string>
}
export interface ApiErrorEnvelope {
error: ApiError
}
export interface ListEnvelope<T> {
items: T[]
nextCursor: string | null
}
export interface MeDto {
userId: string
displayName: string
profileImageUrl: string | null
statusMessage: string | null
}
export interface SessionDto {
sessionId: string
deviceId: string
deviceName: string
createdAt: string
}
export interface TokenPairDto {
accessToken: string
accessTokenExpiresAt: string
refreshToken: string
refreshTokenExpiresAt: string
}
export interface BootstrapWsDto {
url: string
ticket: string
ticketExpiresAt: string
}
export interface MessagePreviewDto {
messageId: string
text: string
createdAt: string
senderUserId: string
}
export interface ConversationSummaryDto {
conversationId: string
type: string
title: string
avatarUrl: string | null
subtitle: string
memberCount: number
isMuted: boolean
isPinned: boolean
sortKey: string
unreadCount: number
lastReadSequence: number
lastMessage: MessagePreviewDto | null
}
export interface MessageSenderDto {
userId: string
displayName: string
profileImageUrl: string | null
}
export interface MessageItemDto {
messageId: string
conversationId: string
clientMessageId: string
kind: string
text: string
createdAt: string
editedAt: string | null
sender: MessageSenderDto
isMine: boolean
serverSequence: number
}
export interface BootstrapResponse {
me: MeDto
session: SessionDto
ws: BootstrapWsDto
conversations: ListEnvelope<ConversationSummaryDto>
}
export interface RegisterAlphaQuickResponse {
account: MeDto
session: SessionDto
tokens: TokenPairDto
bootstrap: BootstrapResponse
}
export interface RefreshTokenRequest {
refreshToken: string
}
export interface RefreshTokenResponse {
tokens: TokenPairDto
}
export interface DeviceRegistrationDto {
installId: string
platform: string
deviceName: string
appVersion: string
}
export interface RegisterAlphaQuickRequest {
displayName: string
inviteCode: string
device: DeviceRegistrationDto
}
export interface PostTextMessageRequest {
clientRequestId: string
body: string
}
export interface UpdateReadCursorRequest {
lastReadSequence: number
}
export interface ReadCursorUpdatedDto {
conversationId: string
accountId: string
lastReadSequence: number
updatedAt: string
}
export interface StoredSession {
apiBaseUrl: string
tokens: TokenPairDto
bootstrap: BootstrapResponse
savedAt: string
}
export interface RealtimeEventEnvelope<T = unknown> {
event: string
eventId: string
occurredAt: string
data: T
}
export interface SessionConnectedDto {
sessionId: string
}

1
src/PhysOn.Web/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,30 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '')
const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'http://127.0.0.1:5082'
return {
plugins: [react()],
server: {
host: '0.0.0.0',
port: 4173,
proxy: {
'/v1': {
target: proxyTarget,
changeOrigin: true,
ws: true,
},
'/health': {
target: proxyTarget,
changeOrigin: true,
},
},
},
preview: {
host: '0.0.0.0',
port: 4173,
},
}
})