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 <cursoragent@cursor.com>
This commit is contained in:
ysj7705andCursor committed 2026-07-16 17:47:06 +09:00
1 parent 5ac6281304
commit 0800fc8571
27 files changed
+735 -394

No files matched your search

+2 -2
View File
@@ -7,12 +7,12 @@
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%232f6b55' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%230f6cbd' d='M9,13H15V19H18V10L12,5.5L6,10V19H9V13M4,21V9L12,3L20,9V21H4Z'/%3E%3C/svg%3E"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&family=Newsreader:opsz,wght@6..72,500;6..72,600&display=swap"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+KR:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
+33
View File
@@ -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<ArrayBuffer> {
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<string, unknown>;
@@ -1,92 +0,0 @@
<script setup lang="ts">
import { APP_GIT_URL, APP_DISPLAY_NAME } from '@/config/app';
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<footer class="footer" :class="{ 'footer--compact': compact }">
<div class="footer__inner">
<span class="footer__brand">{{ APP_DISPLAY_NAME }}</span>
<nav class="footer__nav" aria-label="부가 링크">
<a
v-if="APP_GIT_URL"
class="footer__link"
:href="APP_GIT_URL"
target="_blank"
rel="noopener noreferrer"
>
소스 코드
</a>
<RouterLink class="footer__link" to="/licenses">
오픈소스 라이선스
</RouterLink>
</nav>
</div>
</footer>
</template>
<style scoped>
.footer {
flex: 0 0 auto;
margin-top: auto;
border-top: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 85%, transparent);
backdrop-filter: blur(8px);
}
.footer--compact .footer__inner {
padding: 0.4rem 1rem;
}
.footer__inner {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.25rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.5rem;
align-items: center;
justify-content: space-between;
}
.footer--compact .footer__inner {
max-width: none;
}
.footer__brand {
font-size: 0.85rem;
color: var(--muted);
font-weight: 500;
}
.footer--compact .footer__brand,
.footer--compact .footer__link {
font-size: 0.75rem;
}
.footer__nav {
display: flex;
gap: 1.25rem;
}
.footer__link {
font-size: 0.85rem;
color: var(--ink);
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.footer__link:hover {
border-bottom-color: var(--accent);
color: var(--accent);
}
</style>
@@ -1,21 +1,18 @@
<script setup lang="ts">
import AppFooter from './AppFooter.vue';
withDefaults(
defineProps<{
/** Editor pages: fill viewport, compact footer */
compactFooter?: boolean;
/** Editor pages: fill viewport, no scroll */
editor?: boolean;
}>(),
{ compactFooter: false },
{ editor: false },
);
</script>
<template>
<div class="shell" :class="{ 'shell--editor': compactFooter }">
<div class="shell" :class="{ 'shell--editor': editor }">
<div class="shell__main">
<slot />
</div>
<AppFooter :compact="compactFooter" />
</div>
</template>
@@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{
filename: string;
}>();
defineEmits<{
open: [];
save: [];
close: [];
}>();
</script>
<template>
<header class="toolbar">
<div class="toolbar__left">
<button type="button" class="toolbar__btn" @click="$emit('open')">
열기
</button>
<button type="button" class="toolbar__btn" @click="$emit('save')">
저장
</button>
</div>
<p class="toolbar__title" :title="filename">{{ filename }}</p>
<div class="toolbar__right">
<RouterLink class="toolbar__link" to="/licenses">라이선스</RouterLink>
<button type="button" class="toolbar__btn" @click="$emit('close')">
닫기
</button>
</div>
</header>
</template>
<style scoped>
.toolbar {
flex: 0 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 0.5rem;
min-height: 40px;
padding: 0.35rem 0.75rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
z-index: 2;
}
.toolbar__left {
display: flex;
gap: 0.4rem;
justify-self: start;
}
.toolbar__right {
display: flex;
gap: 0.65rem;
align-items: center;
justify-self: end;
}
.toolbar__title {
margin: 0;
max-width: min(40vw, 28rem);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
font-size: 0.875rem;
font-weight: 600;
color: var(--ink);
}
.toolbar__btn {
border: 1px solid var(--border);
padding: 0.3rem 0.7rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
background: var(--surface);
color: var(--ink);
}
.toolbar__btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.toolbar__link {
font-size: 0.8rem;
font-weight: 500;
color: var(--muted);
text-decoration: none;
}
.toolbar__link:hover {
color: var(--accent);
}
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
import {
ICON_COLORS,
ICON_PATHS,
iconKindForExtension,
} from '@/config/favicons';
const props = withDefaults(
defineProps<{
extension: string;
size?: number;
}>(),
{ size: 40 },
);
const kind = computed(() => iconKindForExtension(props.extension.toLowerCase()));
</script>
<template>
<svg
class="ft-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
:width="size"
:height="size"
aria-hidden="true"
>
<path :fill="ICON_COLORS[kind]" :d="ICON_PATHS[kind]" />
</svg>
</template>
<style scoped>
.ft-icon {
flex-shrink: 0;
display: block;
}
</style>
@@ -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<boolean> {
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<boolean> {
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 };
}
@@ -26,30 +26,27 @@ export function bindSessionPageHideOnce() {
}
export function storeHwpFile(file: File): Promise<void> {
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;
+9 -5
View File
@@ -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<FaviconKind, string> = {
export const ICON_PATHS: Record<FaviconKind, string> = {
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<FaviconKind, string> = {
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<FaviconKind, string> = {
home: '#2f6b55',
export const ICON_COLORS: Record<FaviconKind, string> = {
home: '#0f6cbd',
pdf: '#e53935',
word: '#1e88e5',
excel: '#43a047',
@@ -64,12 +64,16 @@ const EXT_KIND: Record<string, FaviconKind> = {
};
function toDataUri(kind: FaviconKind): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${COLORS[kind]}" d="${PATHS[kind]}"/></svg>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${ICON_COLORS[kind]}" d="${ICON_PATHS[kind]}"/></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
const CACHE = new Map<FaviconKind, string>();
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';
+30 -9
View File
@@ -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;
}
}
*,
@@ -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);
}
+149 -111
View File
@@ -1,72 +1,57 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import { APP_DISPLAY_NAME, isHwp, isOffice } from '@/config/app';
import { createSession, uploadDocument } from '@/api/client';
import FileTypeIcon from '@/components/FileTypeIcon.vue';
import { APP_DISPLAY_NAME } from '@/config/app';
import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle';
import { storeHwpFile } from '@/composables/useSessionLifecycle';
import type { BlankDocumentType } from '@/api/client';
const router = useRouter();
const dragging = ref(false);
const error = ref('');
const busy = ref(false);
const { busy, error, openDocument, createAndOpenBlank } = useOpenDocument();
usePageTitle();
async function handleFile(file: File | undefined) {
if (!file || busy.value) return;
error.value = '';
busy.value = true;
try {
if (isHwp(file.name)) {
await storeHwpFile(file);
await createSession().catch(() => undefined);
await router.push({ name: 'rhwp-editor' });
return;
}
if (!isOffice(file.name)) {
error.value =
'지원하지 않는 형식입니다. docx, xlsx, pptx, pdf, hwp, hwpx 등을 올려 주세요.';
return;
}
await createSession();
const { docId } = await uploadDocument(file);
await router.push({ name: 'onlyoffice-editor', params: { docId } });
} catch (err) {
error.value =
err instanceof Error ? err.message : '파일을 열 수 없습니다.';
} finally {
busy.value = false;
}
}
const blankTypes: {
type: BlankDocumentType;
title: string;
subtitle: string;
}[] = [
{ type: 'docx', title: '문서 (DOCX)', subtitle: '새문서.docx' },
{ type: 'xlsx', title: '스프레드시트 (XLSX)', subtitle: '새문서.xlsx' },
{ type: 'pptx', title: '프레젠테이션 (PPTX)', subtitle: '새문서.pptx' },
{ type: 'pdf', title: 'PDF', subtitle: '새문서.pdf' },
{ type: 'hwpx', title: '한글 문서 (HWPX)', subtitle: '새문서.hwpx' },
];
function onDrop(e: DragEvent) {
dragging.value = false;
const file = e.dataTransfer?.files?.[0];
void handleFile(file);
void openDocument(e.dataTransfer?.files?.[0]);
}
function onInput(e: Event) {
const input = e.target as HTMLInputElement;
void handleFile(input.files?.[0]);
void openDocument(input.files?.[0]);
input.value = '';
}
function onCreate(type: BlankDocumentType) {
void createAndOpenBlank(type);
}
</script>
<template>
<AppShell>
<main class="home">
<div class="home__bg" aria-hidden="true" />
<section class="hero">
<p class="hero__brand">{{ APP_DISPLAY_NAME }}</p>
<h1 class="hero__title">문서를 열고, 고치고, 가져가세요</h1>
<p class="hero__lead">
서버에 영구 저장하지 않습니다. 편집이 끝나면 다운로드하세요. 브라우저를
닫으면 임시 파일은 삭제됩니다.
<header class="home__top">
<h1 class="home__brand">{{ APP_DISPLAY_NAME }}</h1>
<RouterLink class="home__licenses" to="/licenses">라이선스</RouterLink>
</header>
<section class="section">
<h2 class="section__title">파일 열기</h2>
<p class="section__lead">
서버에 영구 저장하지 않습니다. 편집 후 저장(다운로드)하세요.
</p>
<label
@@ -88,11 +73,33 @@ function onInput(e: Event) {
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
</span>
<span class="drop__hint">
오피스·PDF → OnlyOffice · HWP/HWPX → rhwp
오피스·PDF · HWP/HWPX
</span>
</label>
<p v-if="error" class="hero__error" role="alert">{{ error }}</p>
<p v-if="error" class="home__error" role="alert">{{ error }}</p>
</section>
<section class="section">
<h2 class="section__title">새 문서</h2>
<p class="section__lead">빈 문서를 만들어 바로 편집합니다.</p>
<div class="grid">
<button
v-for="item in blankTypes"
:key="item.type"
type="button"
class="tile"
:disabled="busy"
@click="onCreate(item.type)"
>
<FileTypeIcon :extension="item.type" :size="36" />
<span class="tile__text">
<strong>{{ item.title }}</strong>
<span>{{ item.subtitle }}</span>
</span>
</button>
</div>
</section>
</main>
</AppShell>
@@ -100,86 +107,77 @@ function onInput(e: Event) {
<style scoped>
.home {
position: relative;
flex: 1;
display: grid;
place-items: center;
padding: 3rem 1.25rem 2rem;
overflow: hidden;
width: min(920px, 100%);
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
.home__bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 80% 60% at 20% 10%, #c8e0d4 0%, transparent 55%),
radial-gradient(ellipse 70% 50% at 90% 80%, #d9cfc0 0%, transparent 50%),
linear-gradient(165deg, #f3f6f4 0%, #e8ebe6 45%, #f7f2ea 100%);
z-index: 0;
.home__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.75rem;
}
.home__bg::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%232d4a3e' fill-opacity='0.04'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
opacity: 0.9;
}
.hero {
position: relative;
z-index: 1;
width: min(560px, 100%);
text-align: center;
animation: rise 0.7s ease both;
}
.hero__brand {
.home__brand {
margin: 0;
font-family: var(--font-display);
font-size: clamp(2.4rem, 6vw, 3.4rem);
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink);
margin: 0 0 1rem;
line-height: 1.1;
}
.hero__title {
font-family: var(--font-sans);
.home__licenses {
font-size: 0.85rem;
color: var(--muted);
text-decoration: none;
}
.home__licenses:hover {
color: var(--accent);
}
.section {
margin-bottom: 2rem;
}
.section__title {
margin: 0 0 0.35rem;
font-size: 1.15rem;
font-weight: 600;
margin: 0 0 0.65rem;
color: var(--ink-soft);
color: var(--ink);
}
.hero__lead {
margin: 0 0 1.75rem;
.section__lead {
margin: 0 0 1rem;
font-size: 0.9rem;
color: var(--muted);
font-size: 0.95rem;
line-height: 1.55;
}
.drop {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
padding: 2.25rem 1.5rem;
border: 1.5px dashed color-mix(in srgb, var(--accent) 45%, var(--border));
border-radius: 4px;
background: color-mix(in srgb, var(--surface) 70%, transparent);
justify-content: center;
gap: 0.35rem;
min-height: 7.5rem;
padding: 1.5rem 1.25rem;
border: 1.5px dashed var(--border);
border-radius: 8px;
background: var(--card);
cursor: pointer;
transition:
border-color 0.25s ease,
background 0.25s ease,
transform 0.25s ease;
border-color 0.2s ease,
background 0.2s ease;
}
.drop:hover,
.drop--active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 55%, var(--surface));
transform: translateY(-2px);
background: var(--accent-soft);
}
.drop--busy {
@@ -197,6 +195,7 @@ function onInput(e: Event) {
.drop__label {
font-weight: 600;
font-size: 0.95rem;
color: var(--ink);
}
@@ -205,20 +204,59 @@ function onInput(e: Event) {
color: var(--muted);
}
.hero__error {
margin: 1rem 0 0;
color: #9b2c2c;
.home__error {
margin: 0.85rem 0 0;
color: var(--danger);
font-size: 0.9rem;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
.tile {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.9rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
text-align: left;
cursor: pointer;
font-family: inherit;
color: var(--ink);
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.tile:hover:not(:disabled) {
background: var(--card-hover);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
.tile:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tile__text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.tile__text strong {
font-size: 0.92rem;
font-weight: 600;
}
.tile__text span {
font-size: 0.78rem;
color: var(--muted);
}
</style>
+12 -7
View File
@@ -40,22 +40,26 @@ const thirdParty = [
<p v-else class="muted">
소스 저장소 URL은 <code>APP_GIT_URL</code> 상수에 설정할 수 있습니다.
</p>
<p class="muted">
본 앱의 UI는 Microsoft Office와 유사한 레이아웃을 참고했으나,
Microsoft·Office·Word·Excel·PowerPoint 등 Microsoft 상표와 공식 제휴
관계가 없습니다.
</p>
</section>
<section class="card">
<h2>ONLYOFFICE Document Server</h2>
<h2>Euro-Office Document Server</h2>
<p>
Community Edition은 일반적으로
<strong>GNU AGPL v3</strong>로 배포됩니다. 서버에 Document Server를
배포·수정하는 경우 AGPL 의무(소스 제공 등)를 확인하세요.
</p>
<ul>
<li>
<a
href="https://www.onlyoffice.com/"
href="https://github.com/Euro-Office/DocumentServer"
target="_blank"
rel="noopener noreferrer"
>onlyoffice.com</a
>Euro-Office DocumentServer (GitHub)</a
>
</li>
<li>
@@ -63,13 +67,14 @@ const thirdParty = [
href="https://github.com/ONLYOFFICE/DocumentServer"
target="_blank"
rel="noopener noreferrer"
>DocumentServer (GitHub)</a
>ONLYOFFICE DocumentServer (upstream)</a
>
</li>
</ul>
<p class="muted">
ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는 OnlyOffice와
공식 제휴 관계가 없습니다.
Euro-Office Document Server는 ONLYOFFICE Document Server를 기반으로
합니다. ONLYOFFICE는 Ascensio System SIA의 상표입니다. 본 프로젝트는
Euro-Office·OnlyOffice와 공식 제휴 관계가 없습니다.
</p>
</section>
@@ -3,7 +3,9 @@ import { nextTick, onActivated, onMounted, onUnmounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import { fetchEditorConfig } from '@/api/client';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { downloadDocumentUrl, fetchEditorConfig } from '@/api/client';
import { useOpenDocument } from '@/composables/useOpenDocument';
import { usePageTitle } from '@/composables/usePageTitle';
import { useSessionLifecycle } from '@/composables/useSessionLifecycle';
@@ -14,11 +16,14 @@ const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서');
const frameHost = ref<HTMLElement | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
let editor: { destroyEditor?: () => void } | null = null;
let scriptEl: HTMLScriptElement | null = null;
let resizeObserver: ResizeObserver | null = null;
let initialized = false;
const { openDocument } = useOpenDocument();
usePageTitle(filename);
useSessionLifecycle({ enableHeartbeat: true });
@@ -28,7 +33,7 @@ function editorHeightPx(): number {
const h = host.clientHeight;
if (h > 200) return h;
}
return Math.max(400, window.innerHeight - 120);
return Math.max(400, window.innerHeight - 48);
}
function syncIframeSize() {
@@ -108,26 +113,49 @@ onUnmounted(() => {
initialized = false;
});
function goHome() {
function onOpen() {
fileInput.value?.click();
}
function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
void openDocument(input.files?.[0]);
input.value = '';
}
function onSave() {
const docId = route.params.docId as string;
const a = document.createElement('a');
a.href = downloadDocumentUrl(docId);
a.download = filename.value;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
}
function onClose() {
void router.push({ name: 'home' });
}
</script>
<template>
<AppShell :compact-footer="true">
<AppShell :editor="true">
<div class="editor-page">
<header v-if="!error" class="bar">
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
← 홈
</button>
<div class="bar__meta">
<strong>{{ filename }}</strong>
<span>
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
<em>파일 → 저장</em>
</span>
</div>
</header>
<EditorToolbar
v-if="!error"
:filename="filename"
@open="onOpen"
@save="onSave"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv"
@change="onFileSelected"
/>
<EditorErrorState v-if="error" :error="error" context="onlyoffice" />
<div v-show="!error" ref="frameHost" class="editor-frame">
@@ -147,55 +175,16 @@ function goHome() {
background: var(--canvas);
}
.bar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
z-index: 2;
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
border: 0;
}
.editor-frame {
+77 -62
View File
@@ -4,19 +4,24 @@ import { useRouter } from 'vue-router';
import { createEditor, type RhwpEditor } from '@rhwp/editor';
import AppShell from '@/components/AppShell.vue';
import EditorErrorState from '@/components/EditorErrorState.vue';
import EditorToolbar from '@/components/EditorToolbar.vue';
import { ApiError } from '@/api/client';
import { extensionOf, isHwp } from '@/config/app';
import { usePageTitle } from '@/composables/usePageTitle';
import {
clearHwpFile,
loadHwpFile,
storeHwpFile,
useSessionLifecycle,
} from '@/composables/useSessionLifecycle';
import { downloadBlob } from '@/utils/downloadBlob';
defineOptions({ name: 'RhwpEditorView' });
const router = useRouter();
const error = ref<unknown>(null);
const filename = ref('문서.hwp');
const fileInput = ref<HTMLInputElement | null>(null);
let editor: RhwpEditor | null = null;
let initialized = false;
@@ -57,28 +62,76 @@ onUnmounted(() => {
initialized = false;
});
function goHome() {
function onOpen() {
fileInput.value?.click();
}
async function onFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || !editor) return;
if (!isHwp(file.name)) {
error.value = new ApiError(
400,
'unsupported_type',
'HWP/HWPX 파일만 열 수 있습니다.',
);
return;
}
try {
await storeHwpFile(file);
const stored = loadHwpFile();
if (!stored) throw new Error('파일을 읽을 수 없습니다.');
await editor.loadFile(stored.buffer, stored.name);
filename.value = stored.name;
error.value = null;
} catch (err) {
error.value = err;
}
}
async function onSave() {
if (!editor) return;
try {
const ext = extensionOf(filename.value);
const bytes =
ext === 'hwpx' ? await editor.exportHwpx() : await editor.exportHwp();
const name =
ext === 'hwpx' || ext === 'hwp'
? filename.value
: `${filename.value}.hwp`;
downloadBlob(bytes, name);
} catch (err) {
error.value = err;
}
}
function onClose() {
clearHwpFile();
void router.push({ name: 'home' });
}
</script>
<template>
<AppShell :compact-footer="true">
<AppShell :editor="true">
<div class="editor-page">
<header v-if="!error" class="bar">
<button type="button" class="bar__btn bar__btn--ghost" @click="goHome">
← 홈
</button>
<div class="bar__meta">
<strong>{{ filename }}</strong>
<span>
서버에 영구 저장되지 않습니다. 닫기 전에 다운로드하세요.
<em>파일 → 저장</em>
(조판은 한컴과 다를 수 있습니다)
</span>
</div>
</header>
<EditorToolbar
v-if="!error"
:filename="filename"
@open="onOpen"
@save="onSave"
@close="onClose"
/>
<input
ref="fileInput"
class="sr-only"
type="file"
accept=".hwp,.hwpx"
@change="onFileSelected"
/>
<EditorErrorState v-if="error" :error="error" context="rhwp" />
<div v-show="!error" id="rhwp-host" class="editor-frame" />
@@ -96,54 +149,16 @@ function goHome() {
background: var(--canvas);
}
.bar {
flex: 0 0 auto;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.bar__meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 12rem;
}
.bar__meta strong {
font-size: 0.95rem;
}
.bar__meta span {
font-size: 0.75rem;
color: var(--muted);
}
.bar__meta em {
font-style: normal;
font-weight: 600;
color: var(--accent);
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.bar__btn {
border: none;
padding: 0.5rem 0.95rem;
border-radius: 3px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.bar__btn--ghost {
background: transparent;
color: var(--ink);
border: 1px solid var(--border);
border: 0;
}
.editor-frame {