From 74c50a0151f731263fa93e372e2b76ba433853c7 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Sun, 23 Aug 2026 11:14:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(split):=20=EB=B3=B4=EC=A1=B0=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20URL=20=EC=83=81=ED=83=9C=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=EB=AA=A8=EB=93=88=20=E2=80=94=20=3Fside=3Dcrew:|doc:=20=ED=8C=8C=EC=8B=B1=C2=B7=EC=A1=B0=EB=A6=BD(pars?= =?UTF-8?q?eSide=C2=B7withSide=C2=B7sideParam)=20+=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- app/c/[ws]/split.mjs | 35 +++++++++++++++++++++++++++++++++++ test/split.test.mjs | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 app/c/[ws]/split.mjs create mode 100644 test/split.test.mjs diff --git a/app/c/[ws]/split.mjs b/app/c/[ws]/split.mjs new file mode 100644 index 0000000..e8885dc --- /dev/null +++ b/app/c/[ws]/split.mjs @@ -0,0 +1,35 @@ +// 좌우 2분할(보조 패널) — 상태는 URL 쿼리 `?side=` 하나뿐이다. 전역 상태·컨텍스트 없음. +// spec: `crew:` | `doc:`. 순수 함수라 node 테스트가 그대로 임포트한다. + +const TYPES = new Set(['crew', 'doc']); + +/** `?side=` 값 → { type, key } | null(없음·잘못된 spec). key는 디코딩된 원문(한글 slug 그대로). */ +export function parseSide(str) { + if (typeof str !== 'string' || !str) return null; + const i = str.indexOf(':'); + if (i <= 0) return null; + const type = str.slice(0, i); + const key = str.slice(i + 1); + if (!TYPES.has(type) || !key) return null; + return { type, key }; +} + +/** { type, key } → `?side=` 값(인코딩 전 원문). parseSide(sideParam(x))가 x로 돌아온다. */ +export function sideParam(side) { + if (!side || !TYPES.has(side.type) || !side.key) return ''; + return `${side.type}:${side.key}`; +} + +/** href에 side 쿼리를 싣는다(기존 쿼리·해시 보존). sideStr이 비면 side 쿼리를 제거한다. */ +export function withSide(href, sideStr) { + const hashAt = href.indexOf('#'); + const hash = hashAt >= 0 ? href.slice(hashAt) : ''; + const base = hashAt >= 0 ? href.slice(0, hashAt) : href; + const qAt = base.indexOf('?'); + const path = qAt >= 0 ? base.slice(0, qAt) : base; + const params = new URLSearchParams(qAt >= 0 ? base.slice(qAt + 1) : ''); + if (sideStr) params.set('side', sideStr); + else params.delete('side'); + const q = params.toString(); + return `${path}${q ? `?${q}` : ''}${hash}`; +} diff --git a/test/split.test.mjs b/test/split.test.mjs new file mode 100644 index 0000000..7bfcbcf --- /dev/null +++ b/test/split.test.mjs @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSide, sideParam, withSide } from '../app/c/[ws]/split.mjs'; + +test('parseSide — crew/doc spec을 type·key로', () => { + assert.deepEqual(parseSide('crew:haram-editor'), { type: 'crew', key: 'haram-editor' }); + assert.deepEqual(parseSide('doc:notes/brand-voice.md'), { type: 'doc', key: 'notes/brand-voice.md' }); + // key 안의 ':'는 첫 구분자 뒤로 전부 key + assert.deepEqual(parseSide('doc:a:b'), { type: 'doc', key: 'a:b' }); +}); + +test('parseSide — 잘못된 spec은 null', () => { + for (const bad of [null, undefined, '', 'crew', 'crew:', ':x', 'room:abc', 'nope', 42]) { + assert.equal(parseSide(bad), null, String(bad)); + } +}); + +test('sideParam ↔ parseSide 왕복 — 한글 slug 포함', () => { + for (const side of [{ type: 'crew', key: '클로에-편집' }, { type: 'doc', key: 'notes/브랜드 톤.md' }]) { + const str = sideParam(side); + assert.deepEqual(parseSide(str), side); + // URLSearchParams 인코딩을 거쳐도 동일 + const sp = new URLSearchParams(); sp.set('side', str); + assert.deepEqual(parseSide(new URLSearchParams(sp.toString()).get('side')), side); + } + assert.equal(sideParam(null), ''); + assert.equal(sideParam({ type: 'room', key: 'x' }), ''); +}); + +test('withSide — 기존 쿼리·해시 보존, 교체·제거', () => { + assert.equal(withSide('/c/ws1', 'crew:a'), '/c/ws1?side=crew%3Aa'); + assert.equal(withSide('/c/ws1/vault?doc=notes%2Fx.md', 'crew:a'), '/c/ws1/vault?doc=notes%2Fx.md&side=crew%3Aa'); + assert.equal(withSide('/c/ws1?side=crew%3Aa&q=1', 'doc:n.md'), '/c/ws1?side=doc%3An.md&q=1'); + assert.equal(withSide('/c/ws1?side=crew%3Aa&q=1', ''), '/c/ws1?q=1'); + assert.equal(withSide('/c/ws1?side=crew%3Aa', null), '/c/ws1'); + assert.equal(withSide('/c/ws1#top', 'crew:a'), '/c/ws1?side=crew%3Aa#top'); +}); + +test('withSide 결과를 다시 parseSide — 한글 왕복', () => { + const href = withSide('/c/ws1/crew/a', sideParam({ type: 'crew', key: '클로에' })); + const sp = new URLSearchParams(href.slice(href.indexOf('?') + 1)); + assert.deepEqual(parseSide(sp.get('side')), { type: 'crew', key: '클로에' }); +}); From ec50061ed49d9bece394aad43b4630744abca696 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Sun, 23 Aug 2026 11:14:36 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(split):=20=ED=95=9C=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EC=A2=8C=EC=9A=B0=202=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=B8=EB=AC=B8=20=EC=98=86=20(=ED=81=AC=EB=A3=A8=20DM=C2=B7=EA=B8=B0=EC=96=B5=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C),=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=8F=AD?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=88(localStorage=20argo-split-w),=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EB=A7=81=ED=81=AC=20side=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80,=20=ED=81=AC=EB=A3=A8=20=ED=96=89=20'?= =?UTF-8?q?=EC=98=86=EC=97=90=20=EC=97=B4=EA=B8=B0'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- app/c/[ws]/layout.jsx | 69 ++++++++++++++++++++------ app/c/[ws]/split-pane.jsx | 100 ++++++++++++++++++++++++++++++++++++++ app/globals.css | 36 ++++++++++++++ app/i18n.jsx | 8 +++ app/ui.jsx | 1 + 5 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 app/c/[ws]/split-pane.jsx diff --git a/app/c/[ws]/layout.jsx b/app/c/[ws]/layout.jsx index 389e69e..a65ccd4 100644 --- a/app/c/[ws]/layout.jsx +++ b/app/c/[ws]/layout.jsx @@ -1,11 +1,13 @@ 'use client'; // 회사 앱셸 — 라벨 사이드바(회사/크루 그룹 + 사용자 footer) + 헤더(타이틀·검색). -import { use, useCallback, useEffect, useState } from 'react'; +import { Suspense, use, useCallback, useEffect, useState } from 'react'; import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { StarMark, Icon, Avatar, Skeleton, Clock, ArgoSpinner, FeedbackModal, InputModal, api } from '../../ui'; import { useLang, stageLabel } from '../../i18n'; import { useAppUpdate } from '../../use-app-update'; +import { SplitPane } from './split-pane'; +import { parseSide, sideParam, withSide } from './split.mjs'; const fmtRun = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`; const fmtDur = (ms) => (ms == null ? '' : ms >= 60000 ? `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s` : `${Math.round(ms / 1000)}s`); @@ -93,11 +95,30 @@ function TasksDock({ ws }) { ); } -export default function CompanyShell({ children, params }) { +// useSearchParams는 Suspense 경계 안에서 — 정적 프리렌더 bailout(next build 경고)을 막는다(vault 페이지와 동일 패턴) +export default function CompanyShell(props) { + return ( + + + + ); +} + +function Shell({ children, params }) { const { ws } = use(params); const { t } = useLang(); const pathname = usePathname(); const router = useRouter(); + // 좌우 2분할 보조 패널 — 상태는 ?side= 하나. 레이아웃 안의 내부 링크는 전부 withSide를 통과해 + // 주 화면을 옮겨도 패널이 유지된다. 닫기 = side 쿼리 제거. + const searchParams = useSearchParams(); + const sideStr = searchParams.get('side') || ''; + const side = parseSide(sideStr); + const curQuery = searchParams.toString(); + const hereWith = (sp) => withSide(`${pathname}${curQuery ? `?${curQuery}` : ''}`, sp); + const openSide = (spec) => router.replace(hereWith(sideParam(spec))); + const closeSide = () => router.replace(hereWith('')); + const L = (href) => withSide(href, sideStr); // 내부 링크 — 패널 유지 // 같은 페이지 재클릭 = 무동작 — 소프트 내비 전환(Link) 후에도 동일 URL 재이동으로 페이지 상태가 리셋되는 것을 막는다. // 단 modifier/중클릭(새 탭·새 창)은 브라우저 기본 동작 보존 — 좌클릭만 가로챈다(분리 검수 지적 2026-07-24). const navClick = (href) => (e) => { @@ -253,28 +274,28 @@ export default function CompanyShell({ children, params }) {
{t('nav.company')}
- + {t('nav.deck')} - + {t('nav.room')} - + {t('nav.compete')} - + {t('nav.memory')} - + {t('nav.routines')} - + {t('nav.activity')} - + {t('nav.mail')} - + {t('nav.market')} @@ -324,7 +345,12 @@ export default function CompanyShell({ children, params }) { setDragSlug(null); setDropSlug(null); }} style={{ position: 'relative', ...(dragSlug === a.slug ? { opacity: 0.45 } : {}), ...(dropSlug === a.slug && dragSlug ? { boxShadow: 'inset 0 2px 0 var(--primary)' } : {}) }}> - + { + // cmd/ctrl+클릭 = 옆에 열기(새 탭 대신 — 크루 행 한정). 지금 보는 크루는 제외. + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.button === 0) { e.preventDefault(); if (!active) openSide({ type: 'crew', key: a.slug }); return; } + navClick(href)(e); + }} className={`nav-item${active ? ' active' : ''}`} style={{ paddingTop: 6, paddingBottom: 6, paddingRight: 52 }}> {a.slug in tgAgents && ( @@ -345,6 +371,13 @@ export default function CompanyShell({ children, params }) { {/* 고정 토글 — pinned면 상시 골드, 아니면 행 hover 시 노출(.crew-row:hover .crew-pin). preventDefault로 링크 이동 차단. 활성 행 배경이 골드(--primary)라 골드 핀이 묻힌다 — 활성이면 온-골드 전경색(--primary-fg)으로 대비 확보(세션 레일과 동일 규칙, 실사용 신고 2026-07-21). */} + {/* 옆에 열기 — 보조 패널에 이 크루 DM. 지금 보는 크루는 비활성(주 화면과 같은 크루를 두 번 열 이유가 없다) */} + )} +
{data?.missing ? (
@@ -462,6 +496,11 @@ export default function CompanyShell({ children, params }) {
) : children}
+ {side && !data?.missing && ( + a.slug === side.key)?.name ?? side.key) : side.key.split('/').pop().replace(/\.md$/, '')} /> + )} +
{renameTeam != null && ( Math.max(W_MIN, Math.min(Math.round(window.innerWidth * 0.6), Math.round(w))); + +function DocPane({ ws, rel, side }) { + const { t } = useLang(); + const [doc, setDoc] = useState(null); // null 로딩 | { content } | { error } + useEffect(() => { + let alive = true; + setDoc(null); + api(`/api/companies/${ws}/vault?rel=${encodeURIComponent(rel)}`) + .then((d) => { if (alive) setDoc({ content: d.content ?? '' }); }) + .catch((e) => { if (alive) setDoc({ error: String(e?.message || e) }); }); + return () => { alive = false; }; + }, [ws, rel]); + return ( +
+
+ {rel} + {/* 기억 페이지는 ?doc=로 문서 선택을 받는다 — 패널은 유지한 채 주 화면만 이동 */} + + {t('split.openInVault')} + +
+ {doc === null ? <> + : doc.error ?
{t('split.docMissing')}
+ : } +
+ ); +} + +/** side = { type:'crew'|'doc', key } (layout이 parseSide한 값), sideStr = 원문(링크 유지용). */ +export function SplitPane({ ws, side, sideStr, title, onClose }) { + const { t } = useLang(); + const [w, setW] = useState(W_DEFAULT); + const [resizing, setResizing] = useState(false); + const wRef = useRef(W_DEFAULT); + useEffect(() => { + try { const v = Number(localStorage.getItem(W_KEY)); if (v) { wRef.current = clampW(v); setW(wRef.current); } } catch { /* 저장 불가 — 기본 폭 */ } + }, []); + useEffect(() => { + if (!resizing) return; + // 패널은 뷰포트 우측 끝에 붙어 있다 — 폭 = 뷰포트 우측 가장자리 − 커서 x + const onMove = (e) => { wRef.current = clampW(window.innerWidth - e.clientX); setW(wRef.current); }; + const onUp = () => { + setResizing(false); + try { localStorage.setItem(W_KEY, String(wRef.current)); } catch { /* 무시 */ } + }; + document.body.classList.add('split-resizing'); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + document.body.classList.remove('split-resizing'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [resizing]); + + // use(params)는 캐시된 프라미스를 기대한다 — 렌더마다 새 프라미스를 주면 매번 서스펜드한다 + const crewParams = useMemo(() => Promise.resolve({ ws, slug: side.key }), [ws, side.key]); + const fullHref = side.type === 'crew' + ? `/c/${ws}/crew/${encodeURIComponent(side.key)}` + : `/c/${ws}/vault?doc=${encodeURIComponent(side.key)}`; + + return ( + + ); +} diff --git a/app/globals.css b/app/globals.css index b18c47e..3b237d9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1510,6 +1510,42 @@ select option, select optgroup { background: var(--card); color: var(--fg); } .content { padding: 26px 32px 88px; max-width: 1480px; margin: 0 auto; } +/* ─── 좌우 2분할(보조 패널) — ?side= 가 있으면 본문 옆에