From 0800fc857193ea570cfb5cf786f23f82bb7bc60c Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Thu, 16 Jul 2026 17:47:06 +0900 Subject: [PATCH] Migrate to Euro-Office and refresh the editor home experience. Swap Document Server to Euro-Office, add blank-document creation, and restyle the app with an Office-like light/dark theme and compact editor toolbar. Co-authored-by: Cursor --- .env.example | 4 +- Dockerfile | 1 + README.md | 12 +- docker-compose.yml | 12 +- packages/backend/src/routes/documents.ts | 32 +++ .../backend/src/services/blankDocument.ts | 42 +++ packages/backend/src/utils/formats.ts | 2 + packages/backend/templates/blank.docx | Bin 0 -> 925 bytes packages/backend/templates/blank.hwpx | Bin 0 -> 12698 bytes packages/backend/templates/blank.pdf | 18 ++ packages/backend/templates/blank.pptx | Bin 0 -> 4344 bytes packages/backend/templates/blank.xlsx | Bin 0 -> 1911 bytes packages/frontend/index.html | 4 +- packages/frontend/src/api/client.ts | 33 +++ .../frontend/src/components/AppFooter.vue | 92 ------- packages/frontend/src/components/AppShell.vue | 11 +- .../frontend/src/components/EditorToolbar.vue | 99 +++++++ .../frontend/src/components/FileTypeIcon.vue | 38 +++ .../src/composables/useOpenDocument.ts | 84 ++++++ .../src/composables/useSessionLifecycle.ts | 39 ++- packages/frontend/src/config/favicons.ts | 14 +- packages/frontend/src/styles.css | 39 ++- packages/frontend/src/utils/downloadBlob.ts | 18 ++ packages/frontend/src/views/HomeView.vue | 260 ++++++++++-------- packages/frontend/src/views/LicensesView.vue | 19 +- .../src/views/OnlyOfficeEditorView.vue | 117 ++++---- .../frontend/src/views/RhwpEditorView.vue | 139 +++++----- 27 files changed, 735 insertions(+), 394 deletions(-) create mode 100644 packages/backend/src/services/blankDocument.ts create mode 100644 packages/backend/templates/blank.docx create mode 100644 packages/backend/templates/blank.hwpx create mode 100644 packages/backend/templates/blank.pdf create mode 100644 packages/backend/templates/blank.pptx create mode 100644 packages/backend/templates/blank.xlsx delete mode 100644 packages/frontend/src/components/AppFooter.vue create mode 100644 packages/frontend/src/components/EditorToolbar.vue create mode 100644 packages/frontend/src/components/FileTypeIcon.vue create mode 100644 packages/frontend/src/composables/useOpenDocument.ts create mode 100644 packages/frontend/src/utils/downloadBlob.ts diff --git a/.env.example b/.env.example index 7062f4e..045a9ee 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Shared JWT secret (must match OnlyOffice Document Server) +# Shared JWT secret (must match Euro-Office Document Server) JWT_SECRET=change-me-to-a-long-random-secret # URLs PUBLIC_URL=http://localhost:3000 INTERNAL_URL=http://backend:3000 ONLYOFFICE_URL=http://localhost:8080 -ONLYOFFICE_INTERNAL_URL=http://onlyoffice +ONLYOFFICE_INTERNAL_URL=http://documentserver # Session SESSION_TTL_MS=900000 diff --git a/Dockerfile b/Dockerfile index 02997a1..faaa383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ RUN npm install --omit=dev -w @sejong-office/backend && npm cache clean --force COPY --from=build /app/packages/backend/dist ./packages/backend/dist COPY --from=build /app/packages/backend/public ./packages/backend/public +COPY --from=build /app/packages/backend/templates ./packages/backend/templates WORKDIR /app/packages/backend ENV TEMP_DIR=/tmp/sejong diff --git a/README.md b/README.md index 49dc5a9..c4bdc8f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 임시 세션 기반 문서 편집 웹앱입니다. -- **오피스 / PDF** → [ONLYOFFICE Document Server](https://www.onlyoffice.com/) +- **오피스 / PDF** → [Euro-Office Document Server](https://github.com/Euro-Office/DocumentServer) - **HWP / HWPX** → [rhwp](https://github.com/edwardkim/rhwp) (`@rhwp/editor`) 서버에 문서를 **영구 저장하지 않습니다**. 오피스·PDF는 세션 동안만 tmpfs에 두고, 브라우저 종료·TTL로 삭제합니다. HWP/HWPX는 기본적으로 브라우저 메모리에서만 다룹니다. 편집 결과는 **다운로드**로만 가져가세요. @@ -40,10 +40,10 @@ npm run dev:backend npm run dev:frontend ``` -OnlyOffice Document Server는 Docker로만 띄워도 됩니다. +Euro-Office Document Server는 Docker로만 띄워도 됩니다. ```bash -docker compose up onlyoffice +docker compose up documentserver ``` ## 소스 저장소 링크 @@ -52,10 +52,10 @@ docker compose up onlyoffice ## 라이선스 고지 -앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 OnlyOffice(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다. +앱 내 [/licenses](http://localhost:3000/licenses) 페이지에서 Euro-Office(AGPL), rhwp(MIT), 주요 OSS 고지를 볼 수 있습니다. - 본 앱: MIT -- ONLYOFFICE Document Server Community: AGPL-3.0 (배포 시 의무 확인) +- Euro-Office Document Server: AGPL-3.0 (배포 시 의무 확인) - rhwp: MIT — Hancom 상표와 무관한 독립 프로젝트 ## 포맷 안내 @@ -63,6 +63,6 @@ docker compose up onlyoffice | 확장자 | 엔진 | 저장 | |--------|------|------| | hwp, hwpx | rhwp | 원본 확장자로 export | -| pdf, docx, xlsx, pptx 등 | OnlyOffice | 원본 확장자 유지 시도 (`assemblyFormatAsOrigin`) | +| pdf, docx, xlsx, pptx 등 | Euro-Office | 원본 확장자 유지 시도 (`forcesave` + ConvertService) | rhwp는 v0.7.x 기준이며, 복잡한 공공문서에서 한컴과 조판이 다를 수 있습니다. diff --git a/docker-compose.yml b/docker-compose.yml index 762967a..93a1a8e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3000} INTERNAL_URL: ${INTERNAL_URL:-http://backend:3000} ONLYOFFICE_URL: ${ONLYOFFICE_URL:-http://localhost:8080} - ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://onlyoffice} + ONLYOFFICE_INTERNAL_URL: ${ONLYOFFICE_INTERNAL_URL:-http://documentserver} SESSION_TTL_MS: ${SESSION_TTL_MS:-900000} TEMP_DIR: /tmp/sejong PORT: 3000 @@ -16,11 +16,11 @@ services: tmpfs: - /tmp/sejong:size=512m,mode=1777 depends_on: - - onlyoffice + - documentserver restart: unless-stopped - onlyoffice: - image: onlyoffice/documentserver:8.3 + documentserver: + image: ghcr.io/euro-office/documentserver:v9.3.2 ports: - '8080:80' environment: @@ -30,8 +30,8 @@ services: JWT_IN_BODY: 'true' volumes: # Persist DS license/cache lightly; document content still lives in backend tmpfs - - onlyoffice_data:/var/www/onlyoffice/Data + - documentserver_data:/var/www/onlyoffice/Data restart: unless-stopped volumes: - onlyoffice_data: + documentserver_data: diff --git a/packages/backend/src/routes/documents.ts b/packages/backend/src/routes/documents.ts index be505fe..6259460 100644 --- a/packages/backend/src/routes/documents.ts +++ b/packages/backend/src/routes/documents.ts @@ -13,6 +13,10 @@ import { buildEditorConfig, convertToOriginalFormat, } from '../services/onlyOffice.js'; +import { + createBlankDocument, + isBlankType, +} from '../services/blankDocument.js'; import { extensionOf } from '../utils/formats.js'; import { decodeUploadFilename } from '../utils/filename.js'; import { extractToken, verifyOnlyOfficeToken } from '../utils/jwt.js'; @@ -24,6 +28,34 @@ const upload = multer({ export const documentsRouter = Router(); +documentsRouter.post('/new', requireSession, async (req, res) => { + const type = typeof req.body?.type === 'string' ? req.body.type.trim() : ''; + if (!isBlankType(type)) { + res.status(400).json({ + error: 'unsupported_type', + message: '지원 형식: docx, xlsx, pptx, pdf, hwpx', + }); + return; + } + + try { + const doc = await createBlankDocument( + res.locals.sessionId as string, + type, + ); + res.json({ + docId: doc.docId, + filename: doc.filename, + fileType: doc.fileType, + documentType: doc.documentType, + token: doc.token, + }); + } catch (err) { + console.error(err); + res.status(500).json({ error: 'create_failed' }); + } +}); + documentsRouter.post( '/', requireSession, diff --git a/packages/backend/src/services/blankDocument.ts b/packages/backend/src/services/blankDocument.ts new file mode 100644 index 0000000..7f03825 --- /dev/null +++ b/packages/backend/src/services/blankDocument.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { addDocument } from './sessionStore.js'; +import type { SessionDoc } from '../types/session.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const BLANK_TYPES = ['docx', 'xlsx', 'pptx', 'pdf', 'hwpx'] as const; +export type BlankType = (typeof BLANK_TYPES)[number]; + +export function isBlankType(value: string): value is BlankType { + return (BLANK_TYPES as readonly string[]).includes(value); +} + +/** Resolve templates dir for both tsx (src) and compiled dist. */ +async function templatesDir(): Promise { + const candidates = [ + path.resolve(__dirname, '../../templates'), + path.resolve(__dirname, '../templates'), + ]; + for (const dir of candidates) { + try { + await fs.access(dir); + return dir; + } catch { + /* try next */ + } + } + return candidates[0]; +} + +export async function createBlankDocument( + sessionId: string, + type: BlankType, +): Promise { + const dir = await templatesDir(); + const templatePath = path.join(dir, `blank.${type}`); + const buffer = await fs.readFile(templatePath); + const filename = `새문서.${type}`; + return addDocument(sessionId, filename, type, buffer); +} diff --git a/packages/backend/src/utils/formats.ts b/packages/backend/src/utils/formats.ts index 6aeed48..cbe9f56 100644 --- a/packages/backend/src/utils/formats.ts +++ b/packages/backend/src/utils/formats.ts @@ -78,6 +78,8 @@ export function mimeFor(ext: string): string { rtf: 'application/rtf', txt: 'text/plain', csv: 'text/csv', + hwp: 'application/x-hwp', + hwpx: 'application/hwp+zip', }; return map[ext] ?? 'application/octet-stream'; } diff --git a/packages/backend/templates/blank.docx b/packages/backend/templates/blank.docx new file mode 100644 index 0000000000000000000000000000000000000000..5ad0f332b36209a1abf8ccf69ac078ec75e815ab GIT binary patch literal 925 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 literal 0 HcmV?d00001 diff --git a/packages/backend/templates/blank.pdf b/packages/backend/templates/blank.pdf new file mode 100644 index 0000000..90eac1c --- /dev/null +++ b/packages/backend/templates/blank.pdf @@ -0,0 +1,18 @@ +%PDF-1.4 +1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj +2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj +3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>endobj +4 0 obj<< /Length 0 >>stream +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000229 00000 n +trailer<< /Size 5 /Root 1 0 R >> +startxref +279 +%%EOF diff --git a/packages/backend/templates/blank.pptx b/packages/backend/templates/blank.pptx new file mode 100644 index 0000000000000000000000000000000000000000..84ba0adee3f0022bca40afefbfa8d6267276e584 GIT binary patch literal 4344 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 literal 0 HcmV?d00001 diff --git a/packages/backend/templates/blank.xlsx b/packages/backend/templates/blank.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..341fa57a56ac99600203ea74cecbdedfa17d0032 GIT binary patch literal 1911 zcmWIWW@Zs#U|`^2Slj&}=4Z|`Cmu!yh8;`{48lOsXy^RAlGMDC_>jti)Z$pZirk#F zK_~qV8wl+EuB{Wl`j#$xOTZ+#h`s|0YYsf{ky?H4Nul?r?-qL9EgA(cGEJ&hy9KXh z&%VK^ocTU$pOE*t3!3+{wy*v9`B`1zQAyPTU5WP_o+%5(n{UoCW>cAVv@h%4a*=gQ zRjw{(6|!4!+T;1+)+yI_Ol7>nw@pKT+G+2Vd%RyzQ&P90>Z5@61 z6O04--1hz2QT?$_z3%(Zg{$9Nt=;f7D}S>~KnI8J*N&4?fy*{5xcetwlxu#9QJbI?6LKcLDtTJ+w%z9bnL{qpR1TcGuCl`9;}D z`T5zPRMG422uc-uo{L_)w^Epo!?my4(ZiOR-{9l2jZ*L3b}wta|5&K%eP){JtecYC z|DWr&T%3_4wcz&Fgb8kaoqfzTd-mJeG+z|bXlvzU|KZrWH~&uS(=R7$jIR1y9f&z~ zD|+?g9?KVf;Y(Ke=vYpvI(6}tmc*iS9jf}@Hs;RH;pHczIi6y7$Lch98glDDXPW;L>uhCj!Ba-CE-m7gzO-;0obSPZXd2vf_K?GqL);iAW@0*PO4V@1uC{x4*4n?K-;S z7SFwjZJiR=uCBRrIYH%Z!FBd9&PNf>>O%VjZkCxyovTrpuGZVXc%n#h_rV*BGdaa8 z_Z|5$oAIj8O7S(fu1Y;xr{^HM%%T3^?`1D_bNATxK6!FhbkC29Cw(45SB^|`IlTF3 zg|_Ro6%F#WV*g(rHsXD`)PH7moy3$MpPs*BM-74Y$#?8 zSRYIof+F7bj5psQ10I$Owf=&Y{;?4XEtATcEf{4EWcfs2vxYIUJTqoV_^P3Nx zt1AnysdhEj-fm2kTpXWLRIop`T-_kb!)9&Zjf2xg+xBgIKV!a?v3M|JAbYXq%bpdF z&opl7?OdwYKZALy)aRC2Mw9RRB_4IYE^Bx}>4N#O8*BFkD6X;i_w^r|*?AW<{RX@0P4d^9^1uTBX&dqxF=@b7|hm9`$AxwnU-S zs$ZXkx1L<2{rGvm`tOr>&qkKDdK^nWzkPO}@qvT=Z0BwN+q}D;KPT{#Mb>ML literal 0 HcmV?d00001 diff --git a/packages/frontend/index.html b/packages/frontend/index.html index f860ad7..67dc159 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -7,12 +7,12 @@ diff --git a/packages/frontend/src/api/client.ts b/packages/frontend/src/api/client.ts index efb48b9..1ecdcc8 100644 --- a/packages/frontend/src/api/client.ts +++ b/packages/frontend/src/api/client.ts @@ -66,6 +66,39 @@ export async function uploadDocument(file: File): Promise<{ ); } +export type BlankDocumentType = 'docx' | 'xlsx' | 'pptx' | 'pdf' | 'hwpx'; + +export async function createBlankDocument(type: BlankDocumentType): Promise<{ + docId: string; + filename: string; + fileType: string; + documentType: string; + token: string; +}> { + return parseJson( + await fetch('/api/documents/new', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type }), + }), + ); +} + +export async function fetchDocumentContent( + docId: string, + token: string, +): Promise { + const res = await fetch( + `/api/documents/${docId}/content?token=${encodeURIComponent(token)}`, + { credentials: 'include' }, + ); + if (!res.ok) { + throw new ApiError(res.status, 'content_failed', '문서를 불러오지 못했습니다.'); + } + return res.arrayBuffer(); +} + export type EditorConfigResponse = { documentType: string; document: Record; diff --git a/packages/frontend/src/components/AppFooter.vue b/packages/frontend/src/components/AppFooter.vue deleted file mode 100644 index 9b8b27d..0000000 --- a/packages/frontend/src/components/AppFooter.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/packages/frontend/src/components/AppShell.vue b/packages/frontend/src/components/AppShell.vue index e62ddd1..b348021 100644 --- a/packages/frontend/src/components/AppShell.vue +++ b/packages/frontend/src/components/AppShell.vue @@ -1,21 +1,18 @@ diff --git a/packages/frontend/src/components/EditorToolbar.vue b/packages/frontend/src/components/EditorToolbar.vue new file mode 100644 index 0000000..7d65cc9 --- /dev/null +++ b/packages/frontend/src/components/EditorToolbar.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/packages/frontend/src/components/FileTypeIcon.vue b/packages/frontend/src/components/FileTypeIcon.vue new file mode 100644 index 0000000..57555ac --- /dev/null +++ b/packages/frontend/src/components/FileTypeIcon.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/src/composables/useOpenDocument.ts b/packages/frontend/src/composables/useOpenDocument.ts new file mode 100644 index 0000000..0ff9ce6 --- /dev/null +++ b/packages/frontend/src/composables/useOpenDocument.ts @@ -0,0 +1,84 @@ +import { ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { isHwp, isOffice } from '@/config/app'; +import { + createBlankDocument, + createSession, + fetchDocumentContent, + uploadDocument, + type BlankDocumentType, +} from '@/api/client'; +import { + storeHwpBuffer, + storeHwpFile, +} from '@/composables/useSessionLifecycle'; + +export function useOpenDocument() { + const router = useRouter(); + const busy = ref(false); + const error = ref(''); + + async function openDocument(file: File | undefined): Promise { + if (!file || busy.value) return false; + error.value = ''; + busy.value = true; + + try { + if (isHwp(file.name)) { + await storeHwpFile(file); + await createSession().catch(() => undefined); + await router.push({ name: 'rhwp-editor' }); + return true; + } + + if (!isOffice(file.name)) { + error.value = + '지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.'; + return false; + } + + await createSession(); + const { docId } = await uploadDocument(file); + await router.push({ name: 'onlyoffice-editor', params: { docId } }); + return true; + } catch (err) { + error.value = + err instanceof Error ? err.message : '파일을 열 수 없습니다.'; + return false; + } finally { + busy.value = false; + } + } + + async function createAndOpenBlank(type: BlankDocumentType): Promise { + if (busy.value) return false; + error.value = ''; + busy.value = true; + + try { + await createSession(); + const doc = await createBlankDocument(type); + + if (type === 'hwpx') { + const buffer = await fetchDocumentContent(doc.docId, doc.token); + storeHwpBuffer(doc.filename, buffer); + await router.push({ name: 'rhwp-editor' }); + return true; + } + + await router.push({ + name: 'onlyoffice-editor', + params: { docId: doc.docId }, + }); + return true; + } catch (err) { + error.value = + err instanceof Error ? err.message : '새 문서를 만들 수 없습니다.'; + return false; + } finally { + busy.value = false; + } + } + + return { busy, error, openDocument, createAndOpenBlank }; +} diff --git a/packages/frontend/src/composables/useSessionLifecycle.ts b/packages/frontend/src/composables/useSessionLifecycle.ts index 74dade7..a63691f 100644 --- a/packages/frontend/src/composables/useSessionLifecycle.ts +++ b/packages/frontend/src/composables/useSessionLifecycle.ts @@ -26,30 +26,27 @@ export function bindSessionPageHideOnce() { } export function storeHwpFile(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const buffer = reader.result as ArrayBuffer; - const bytes = new Uint8Array(buffer); - let binary = ''; - const chunk = 0x8000; - for (let i = 0; i < bytes.length; i += chunk) { - binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); - } - sessionStorage.setItem( - HWP_FILE_KEY, - JSON.stringify({ - name: file.name, - base64: btoa(binary), - }), - ); - resolve(); - }; - reader.onerror = () => reject(reader.error); - reader.readAsArrayBuffer(file); + return file.arrayBuffer().then((buffer) => { + storeHwpBuffer(file.name, buffer); }); } +export function storeHwpBuffer(name: string, buffer: ArrayBuffer): void { + const bytes = new Uint8Array(buffer); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < bytes.length; i += chunk) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); + } + sessionStorage.setItem( + HWP_FILE_KEY, + JSON.stringify({ + name, + base64: btoa(binary), + }), + ); +} + export function loadHwpFile(): { name: string; buffer: ArrayBuffer } | null { const raw = sessionStorage.getItem(HWP_FILE_KEY); if (!raw) return null; diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index 0b93c7d..8cab22d 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -5,7 +5,7 @@ import { extensionOf } from '@/config/app'; -type FaviconKind = +export type FaviconKind = | 'home' | 'pdf' | 'word' @@ -16,7 +16,7 @@ type FaviconKind = | 'csv' | 'file'; -const PATHS: Record = { +export const ICON_PATHS: Record = { home: 'M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z', pdf: 'M19 3H5C3.9 3 3 3.9 3 5V19C3 20.1 3.9 21 5 21H19C20.1 21 21 20.1 21 19V5C21 3.9 20.1 3 19 3M9.5 11.5C9.5 12.3 8.8 13 8 13H7V15H5.5V9H8C8.8 9 9.5 9.7 9.5 10.5V11.5M14.5 13.5C14.5 14.3 13.8 15 13 15H10.5V9H13C13.8 9 14.5 9.7 14.5 10.5V13.5M18.5 10.5H17V11.5H18.5V13H17V15H15.5V9H18.5V10.5M12 10.5H13V13.5H12V10.5M7 10.5H8V11.5H7V10.5Z', word: 'M15.5,17H14L12,9.5L10,17H8.5L6.1,7H7.8L9.34,14.5L11.3,7H12.7L14.67,14.5L16.2,7H17.9M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3Z', @@ -30,8 +30,8 @@ const PATHS: Record = { file: 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z', }; -const COLORS: Record = { - home: '#2f6b55', +export const ICON_COLORS: Record = { + home: '#0f6cbd', pdf: '#e53935', word: '#1e88e5', excel: '#43a047', @@ -64,12 +64,16 @@ const EXT_KIND: Record = { }; function toDataUri(kind: FaviconKind): string { - const svg = ``; + const svg = ``; return `data:image/svg+xml,${encodeURIComponent(svg)}`; } const CACHE = new Map(); +export function iconKindForExtension(ext: string): FaviconKind { + return EXT_KIND[ext] ?? 'file'; +} + export function faviconHrefFor(filename?: string | null): string { const name = filename?.trim(); let kind: FaviconKind = 'home'; diff --git a/packages/frontend/src/styles.css b/packages/frontend/src/styles.css index 86d5e2c..7084ad8 100644 --- a/packages/frontend/src/styles.css +++ b/packages/frontend/src/styles.css @@ -1,14 +1,35 @@ :root { - --ink: #1c2b24; - --ink-soft: #2f4038; - --muted: #5c6b63; - --accent: #2f6b55; - --accent-soft: #d7ebe2; - --surface: #faf9f6; - --canvas: #eef1ee; - --border: #d0d7d2; + --ink: #242424; + --ink-soft: #424242; + --muted: #616161; + --accent: #0f6cbd; + --accent-soft: #e8f3fc; + --surface: #ffffff; + --canvas: #f3f3f3; + --border: #e0e0e0; + --card: #ffffff; + --card-hover: #f5f5f5; + --danger: #c62828; --font-sans: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; - --font-display: 'Newsreader', 'IBM Plex Sans KR', serif; + --font-display: 'IBM Plex Sans KR', 'Apple SD Gothic Neo', sans-serif; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root { + --ink: #f5f5f5; + --ink-soft: #e0e0e0; + --muted: #a0a0a0; + --accent: #4da3ff; + --accent-soft: #1a2f42; + --surface: #2b2b2b; + --canvas: #1f1f1f; + --border: #3a3a3a; + --card: #2b2b2b; + --card-hover: #333333; + --danger: #ef9a9a; + color-scheme: dark; + } } *, diff --git a/packages/frontend/src/utils/downloadBlob.ts b/packages/frontend/src/utils/downloadBlob.ts new file mode 100644 index 0000000..7d437bc --- /dev/null +++ b/packages/frontend/src/utils/downloadBlob.ts @@ -0,0 +1,18 @@ +export function downloadBlob( + data: Blob | ArrayBuffer | Uint8Array, + filename: string, +) { + const blob = + data instanceof Blob + ? data + : new Blob([data instanceof Uint8Array ? data.slice() : data]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index b11babf..ab5dc5e 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -1,72 +1,57 @@