diff --git a/.gitignore b/.gitignore index 55f33da6..13943375 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ dist-server/ !/docs/selfhost.md !/docs/privacy-sync.md /PRODUCT-SPEC.md + +# 메신저 디자인 시안 스크린샷 — 로컬 실측 산출물(유건 지시 2026-09-03: 커밋 금지) +apps/messenger/design/shots/ diff --git a/app/api/companies/[ws]/msgr/route.js b/app/api/companies/[ws]/msgr/route.js new file mode 100644 index 00000000..004683dc --- /dev/null +++ b/app/api/companies/[ws]/msgr/route.js @@ -0,0 +1,94 @@ +// 팀 메신저 — 이 회사 크루를 조직에 등록/해제/조회. 서버가 아니라 **이 기기의 기기 세션(크루 소유자 JWT)**으로 +// Supabase msgr_crews에 쓴다(RLS: owner_user_id = auth.uid()). 새 공개 API를 뚫지 않는다 — 조직·채널·메시지는 메신저 앱이 직접 다룬다. +// 등록이 하나라도 있으면 company.json.msgr.enabled=true → 게이트웨이 매니저가 브리지(폴러)·드레인 워커를 켠다(src/gateway.mjs). +import { guardCompany, csrfDenied, authError, requestLang } from '../../../../auth.mjs'; +import { apiError } from '../../../../apimsg.mjs'; +import { sessionClient } from '../../../../../src/gateway/msgr.mjs'; +import { listAgents } from '../../../../../src/hub.mjs'; +import { loadCompany, updateCompany } from '../../../../../src/workspace.mjs'; + +const ALLOW = new Set(['all', 'list', 'owner']); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const upstream = (where, e, lang) => { console.error(`[argo] msgr ${where}:`, e?.message ?? e); return apiError('msgr_upstream', lang); }; // PG 원문은 화면이 아니라 로그로 + +async function myRegistrations(c, ws) { + const { data, error } = await c.client.from('msgr_crews') + .select('id, org_id, slug, display_name, hosting, status, allow, allow_users, last_seen_at, msgr_orgs(name, slug)') + .eq('owner_user_id', c.uid).eq('ws_id', ws); + if (error) throw new Error(error.message); + return data ?? []; +} +async function myOrgs(c) { + const { data, error } = await c.client.from('msgr_org_members').select('org_id, role, msgr_orgs(id, name, slug)').eq('user_id', c.uid).is('removed_at', null); // 본인 행만(멤버 select 정책은 조직 전원 행을 준다 — 검수 MEDIUM-2) + if (error) throw new Error(error.message); + const orgs = (data ?? []).filter((m) => m.msgr_orgs).map((m) => ({ id: m.org_id, name: m.msgr_orgs.name, slug: m.msgr_orgs.slug, role: m.role })); + if (!orgs.length) return orgs; + // 조직별 멤버(허용 범위 '지정 멤버' 선택지) — 본인 제외, 표시명·역할만 + const { data: mems, error: e2 } = await c.client.from('msgr_org_members').select('org_id, user_id, role, display_name').in('org_id', orgs.map((o) => o.id)).is('removed_at', null); + if (e2) throw new Error(e2.message); + for (const o of orgs) o.members = (mems ?? []).filter((m) => m.org_id === o.id && m.user_id !== c.uid).map((m) => ({ id: m.user_id, name: m.display_name || m.user_id.slice(0, 8), role: m.role })); + // 조직 정책(H-0): 허용 범위가 잠겼으면 카드가 선택지를 잠그고 정책값을 보여준다(서버 트리거 msgr_crew_policy_gate가 최종) + const { data: pols } = await c.client.from('msgr_org_policies').select('org_id, allow_default, allow_locked, crew_memory_default, crew_memory_locked, approval_high_by').in('org_id', orgs.map((o) => o.id)); + for (const o of orgs) o.policy = (pols ?? []).find((p) => p.org_id === o.id) ?? null; + return orgs; +} +async function syncEnabled(ws, c) { + let regs; + try { regs = await myRegistrations(c, ws); } catch (e) { console.error('[argo] msgr 등록 조회 실패 — enabled 유지:', e.message); return []; } // 일시 오류로 브리지를 끄지 않는다 + const enabled = regs.some((r) => r.status === 'active'); + const company = await loadCompany(ws); + if (!!company.msgr?.enabled !== enabled) await updateCompany(ws, { msgr: { ...(company.msgr ?? {}), enabled } }); + return regs; +} + +export async function GET(_req, { params }) { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const c = await sessionClient().catch(() => null); + if (!c) return Response.json({ signedIn: false, orgs: [], crews: [] }); + try { + const [orgs, crews] = await Promise.all([myOrgs(c), myRegistrations(c, ws)]); + return Response.json({ signedIn: true, uid: c.uid, orgs, crews }); + } catch (e) { return upstream('GET', e, await requestLang()); } +} + +/** 등록 { orgId, slug, allow?, allowUsers? } — 크루 카드(agents/.md)가 있어야 한다. 이미 있으면 allow만 갱신. */ +export async function POST(req, { params }) { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const cs = await csrfDenied(req); if (cs) return cs; + const lang = await requestLang(); + // 기본 'owner' — 조직 정책 테이블(부록 H-0) 전까지는 가장 좁게 시작하고 크루 시트/카드에서 연다(유건 결정 2026-09-03) + const { orgId, slug, allow = 'owner', allowUsers = [] } = await req.json().catch(() => ({})); + const users = Array.isArray(allowUsers) ? allowUsers : []; + if (!UUID.test(String(orgId ?? '')) || !slug || !ALLOW.has(allow) || !users.every((u) => UUID.test(String(u)))) return apiError('msgr_bad_request', lang); + const agent = (await listAgents(ws)).find((a) => a.slug === slug); + if (!agent) return apiError('msgr_crew_not_found', lang); + const c = await sessionClient().catch(() => null); + if (!c) return authError('auth_required', lang); + const row = { + org_id: orgId, owner_user_id: c.uid, ws_id: ws, slug, display_name: agent.name || slug, role_text: agent.role || null, + hosting: process.env.ARGO_TENANT_OWNER ? 'resident' : 'local', status: 'active', allow, + allow_users: users.slice(0, 200), + }; + const { data, error } = await c.client.from('msgr_crews').upsert(row, { onConflict: 'org_id,owner_user_id,ws_id,slug' }).select('id').single(); + if (error) return upstream('POST', error, lang); + const crews = await syncEnabled(ws, c); + return Response.json({ ok: true, id: data.id, crews }); +} + +/** 해제 { orgId, slug } — 행 삭제(감사는 서버 트리거 몫). 마지막 등록이 사라지면 브리지도 꺼진다. */ +export async function DELETE(req, { params }) { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const cs = await csrfDenied(req); if (cs) return cs; + const lang = await requestLang(); + const { orgId, slug } = await req.json().catch(() => ({})); + if (!UUID.test(String(orgId ?? '')) || !slug) return apiError('msgr_bad_request', lang); + const c = await sessionClient().catch(() => null); + if (!c) return authError('auth_required', lang); + const { error } = await c.client.from('msgr_crews').delete().eq('org_id', orgId).eq('owner_user_id', c.uid).eq('ws_id', ws).eq('slug', slug); + if (error) return upstream('DELETE', error, lang); + const crews = await syncEnabled(ws, c); + return Response.json({ ok: true, crews }); +} diff --git a/app/apimsg.mjs b/app/apimsg.mjs index 45d96c9e..9e211685 100644 --- a/app/apimsg.mjs +++ b/app/apimsg.mjs @@ -24,6 +24,10 @@ export const API_MSG = { // 회의실(app/api/companies/[ws]/room·sessions) — 크루 발언 중 새 회의·전환·마치기 거절(코어 assertRoomIdle의 ROOM_BUSY). // 화면은 errorCode로 사전(room.busyGate)을 다시 그리고, 이 문구는 API 소비자·로그용 표시 언어 본문(#393 DELETE 문구 계승). room_busy: { status: 409, ko: '발언이 진행 중입니다 — 끝난 뒤 다시 시도해 주세요.', en: 'A crew is still speaking — try again after it finishes.' }, + // 팀 메신저 크루 등록(app/api/companies/[ws]/msgr) + msgr_bad_request: { status: 400, ko: '조직 id·크루·허용 범위(all|list|owner)를 확인해 주세요', en: 'Check the organization id, crew, and allow scope (all|list|owner)' }, + msgr_crew_not_found: { status: 404, ko: '크루가 없습니다', en: 'Crew not found' }, + msgr_upstream: { status: 502, ko: '조직 서버 응답 오류 — 잠시 후 다시 시도해 주세요', en: 'Organization server error — please try again shortly' }, }; /** 기능 라우트 공통 오류 응답. lang은 ko|en(그 외 값·미지정은 ko). 미등록 코드는 throw — diff --git a/app/c/[ws]/settings/page.jsx b/app/c/[ws]/settings/page.jsx index 988a5425..842ba63f 100644 --- a/app/c/[ws]/settings/page.jsx +++ b/app/c/[ws]/settings/page.jsx @@ -185,6 +185,7 @@ function Settings({ params }) { + @@ -340,6 +341,9 @@ const THEME_SWATCHES = { graphite: ['#f0f0f0', '#202020', '#1a1a1a'], // 자동 — 밝은 판·어두운 판을 함께 보여 시스템 따라감을 드러낸다 'graphite-light': ['#f0f0f0', '#ffffff', '#1a1a1a'], 'graphite-dark': ['#202020', '#252525', '#ededed'], + linen: ['#e9e6df', '#1f1e1b', '#e8e400'], // 자동 — 그레이지 판·차콜 판 + 옐로 마크 + 'linen-light': ['#e9e6df', '#fbfaf7', '#1f1e1b'], + 'linen-dark': ['#1f1e1b', '#2a2926', '#ececea'], argo: ['#e3e5d6', '#e9ebdd', '#22241c'], calm: ['#eff1f4', '#f8f9fb', '#5a6b8c'], 'calm-dark': ['#1b1e24', '#22262e', '#8098bd'], @@ -370,9 +374,9 @@ const THEME_SWATCHES = { }; // 아르고 시그니처 = 라이트/다크/시스템 3-모드. 나머지 테마는 "다른 스킨"으로 분리(모드 토글과 중복 제거). -// 기본 패밀리 2종(그래파이트=기본, 아르고=시그니처) × 모드 3종(시스템/라이트/다크). 패밀리 코드: 시스템 자동은 +// 기본 패밀리 3종(그래파이트=기본, 아르고=시그니처, 리넨=메신저 기본) × 모드 3종(시스템/라이트/다크). 패밀리 코드: 시스템 자동은 // 접미 없음, 고정은 -light/-dark. 다른 스킨(목록)은 패밀리 밖이라 모드 세그먼트가 적용되지 않는다. -const FAMILIES = [['graphite', 'settings.family.graphite'], ['argo', 'settings.family.argo']]; +const FAMILIES = [['graphite', 'settings.family.graphite'], ['argo', 'settings.family.argo'], ['linen', 'settings.family.linen']]; const MODE_SUFFIX = [['', 'settings.mode.system'], ['-light', 'settings.mode.light'], ['-dark', 'settings.mode.dark']]; const FAMILY_CODES = FAMILIES.flatMap(([f]) => MODE_SUFFIX.map(([sfx]) => `${f}${sfx}`)); const ARGO_CODES = FAMILY_CODES; @@ -734,6 +738,112 @@ function TrashCard({ ws }) { /** 로그인만으로 붙는 외부 서비스(커넥터) — 카탈로그 × 이 회사 연결 상태. "연결"은 브라우저 동의 창을 열고 **끝나기를 기다리지 않는다**(사용자가 구글에서 로그인하는 동안 응답이 막히면 화면이 죽은 것처럼 보인다). 완료는 목록을 다시 읽어 상태로 관측한다 — 코어와 같은 계약. */ +/** 팀 메신저 — 이 회사 크루를 조직에 등록(파견)/해제하고 허용 범위를 고른다. 조직·채널·메시지는 메신저 앱이 다룬다. + 데이터는 /api/companies/[ws]/msgr(기기 세션 JWT로 Supabase msgr_crews에 씀). 기본 허용 범위는 '나만'(부록 H — 정책 테이블 전까지 가장 좁게). */ +function MsgrCard({ ws, agents }) { + const { t } = useLang(); + const [st, setSt] = useState(null); // { signedIn, orgs:[{id,name,role,members}], crews:[등록 행] } + const [orgId, setOrgId] = useState(''); + const [busy, setBusy] = useState(''); + const [err, setErr] = useState(''); + const load = useCallback(() => api(`/api/companies/${ws}/msgr`) + .then((d) => { setSt(d); setOrgId((cur) => cur && d.orgs?.some((o) => o.id === cur) ? cur : (d.orgs?.[0]?.id ?? '')); }) + .catch(() => { setSt({ signedIn: false, orgs: [], crews: [] }); setErr(t('settings.msgr.err.load')); }), [ws, t]); + useEffect(() => { load(); }, [load]); + const org = st?.orgs?.find((o) => o.id === orgId); + const regOf = (slug) => st?.crews?.find((r) => r.org_id === orgId && r.slug === slug && r.status === 'active'); + const bridgeOn = !!st?.crews?.some((r) => r.status === 'active'); + async function register(slug, allow, allowUsers) { + if (busy) return; setBusy(slug); setErr(''); + try { await api(`/api/companies/${ws}/msgr`, { orgId, slug, allow, allowUsers }); await load(); } + catch (e) { setErr(String(e.message)); } finally { setBusy(''); } + } + async function unregister(slug) { + if (busy) return; setBusy(slug); setErr(''); + try { + const r = await fetch(`/api/companies/${ws}/msgr`, { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ orgId, slug }) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `${r.status}`); + await load(); + } catch (e) { setErr(String(e.message)); } finally { setBusy(''); } + } + return ( +
+
+ {t('settings.msgr.title')} + {st?.signedIn && {bridgeOn ? t('settings.msgr.bridge.onShort') : t('settings.msgr.bridge.offShort')}} +
+

{t('settings.msgr.help')}

+ {st === null && } + {st && !st.signedIn &&

{t('settings.msgr.notSignedIn')}

} + {st?.signedIn && !st.orgs?.length &&

{t('settings.msgr.noOrg')}

} + {st?.signedIn && !!st.orgs?.length && (<> + + {org?.policy && ( +

+ {t('settings.msgr.policy')} + {t('settings.msgr.policy.summary', { allow: t(`settings.msgr.allow.${org.policy.allow_default}`) + (org.policy.allow_locked ? t('settings.msgr.policy.locked') : ''), approver: t(`settings.msgr.policy.approver.${org.policy.approval_high_by ?? 'admin'}`), memory: (org.policy.crew_memory_default === false ? t('settings.msgr.policy.memory.off') : t('settings.msgr.policy.memory.on')) + (org.policy.crew_memory_locked ? t('settings.msgr.policy.locked') : '') })} +

+ )} +

{t('settings.msgr.tierNote')}

+ {!agents.length &&

{t('settings.msgr.noCrews')}

} + {agents.map((a) => { + const reg = regOf(a.slug); + const allow = reg?.allow ?? 'owner'; + const picked = reg?.allow_users ?? []; + return ( +
+
+
+ {a.name} + {a.role} + {reg ? t('settings.msgr.registered') : t('settings.msgr.notRegistered')} +
+ {reg ? ( + + ) : ( + + )} +
+ {reg && ( +
+ {t('settings.msgr.allow')} +
+ {['all', 'list', 'owner'].map((v) => ( + + ))} +
+ {!!org?.policy?.allow_locked && {t('settings.msgr.allow.locked')}} + {allow === 'list' && ( +
+ {t('settings.msgr.allow.pick')} + {!org?.members?.length && {t('settings.msgr.allow.noMembers')}} + {org?.members?.map((m) => { + const on = picked.includes(m.id); + return ; + })} +
+ )} +
+ )} +
+ ); + })} +

{t('settings.msgr.policyNote')}

+ )} + {err &&

{err}

} +
+ ); +} + function ConnectorsCard({ ws }) { const { t, lang } = useLang(); const [rows, setRows] = useState(null); diff --git a/app/globals.css b/app/globals.css index 0c2c04af..a85dcedc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -61,6 +61,9 @@ --accent: #d8ab4a; /* 계기 액센트 — 골드(계기·게이지 가독 위해 primary보다 살짝 깊게) */ --accent-rgb: 216, 171, 74; + /* --mark 가족 — '표시' 색(점·노드·결재 머리띠). 기본은 액센트와 같고 linen만 형광 옐로로 갈라진다. */ + --mark: var(--accent); --mark-rgb: var(--accent-rgb); --mark-fg: var(--primary-fg); --mark-wash: var(--primary-soft); + --shadow-float: 0 8px 24px rgba(60, 50, 20, 0.12); /* 캔버스(기억 그래프)용 RGB 트리플릿 — --fg / --card와 동기 */ --ink-rgb: 34, 30, 21; @@ -1382,6 +1385,201 @@ :root[data-theme='graphite'] .msg-user, :root[data-theme='graphite-dark'] .msg-user { background: var(--secondary); color: var(--fg); } +/* [linen] 웜 뉴트럴 — 메신저 시안(apps/messenger/design, 2026-09-03 유건 승인)에서 확정한 값. + 그레이지 캔버스 위 순백 카드, **레일은 라이트에서도 차콜**(핀 프레임 문법 — cream-pop처럼 .side 스코프에서 + 토큰을 갈아끼운다), 액션은 그래파이트와 같은 명도 반전. 형광 옐로는 --accent가 아니라 **--mark**다: + --accent 소비자(Dial·Bars·meter·스피너·데크 알림)가 저대비 옐로 계기판이 되지 않도록 분리했다. + 토큰 값의 원천은 apps/messenger/design/tokens.css — 양쪽을 손으로 맞추지 말고 그 파일에서 다시 생성한다. + 시스템 자동/고정 세 갈래의 동기는 test/theme-linen-sync가 잠근다(그래파이트와 같은 계약). */ +:root[data-theme='linen'] { + color-scheme: light; + --bg: #e9e6df; + --card: #fbfaf7; + --card-2: #f1efe9; + --secondary: #e0dcd3; + --fg: #26241f; + --fg-2: #5a564d; + --fg-3: #6b655e; + --border: rgba(38, 36, 31, 0.14); + --border-soft: rgba(38, 36, 31, 0.07); + --primary: #1f1e1b; + --primary-strong: #000000; + --primary-fg: #f4f2ec; + --primary-fg-dim: rgba(244, 242, 236, 0.72); + --primary-fg-line: rgba(244, 242, 236, 0.28); + --primary-soft: rgba(31, 30, 27, 0.07); + --ok: #3f7a52; --ok-soft: rgba(63, 122, 82, 0.12); + --info: #56688a; --info-soft: rgba(86, 104, 138, 0.12); + --warn: #9a6f1e; --warn-soft: rgba(154, 111, 30, 0.13); + --danger: #a8433a; --danger-soft: rgba(168, 67, 58, 0.11); + --ring: rgba(31, 30, 27, 0.32); + --selection: rgba(232, 228, 0, 0.35); + --overlay: rgba(31, 30, 27, 0.36); + --shadow-card: none; /* 그래파이트 규칙 계승 — 표면 단계로만 위계. 플로팅 컴포저·팝업만 --shadow-float */ + --shadow-float: 0 8px 24px rgba(31, 30, 27, 0.14); + --accent: #1f1e1b; --accent-rgb: 31, 30, 27; /* primary와 같은 값 — 계기판(Dial·Bars·meter)은 차콜. 옐로는 --mark로 분리 */ + --mark: #e8e400; --mark-rgb: 232, 228, 0; --mark-fg: #1f1e1b; /* 형광 옐로 — 점·노드·결재 머리띠·전송 버튼에만(면 채움은 그 둘뿐) */ + --mark-wash: color-mix(in srgb, var(--mark) 7%, var(--card)); + --ink-rgb: 38, 36, 31; --paper-rgb: 251, 250, 247; +} +:root[data-theme='linen'] .side, :root[data-theme='linen'] .msgr-composer { + background: #1f1e1b; border-right-color: rgba(230, 226, 216, 0.1); color: #e6e2d8; + --bg: #1f1e1b; /* 스코프 안 --bg = 레일 자기 배경(아바타 링·후광이 캔버스색으로 새지 않게 — 검수 H1) */ + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); /* 차콜 위 상태색은 다크 갈래 값 */ + --fg: #e6e2d8; --fg-2: #a8a297; --fg-3: #8d877c; + --border: rgba(230, 226, 216, 0.12); --border-soft: rgba(230, 226, 216, 0.06); + --card: #2a2926; --card-2: #33322e; --secondary: #3c3a35; + --primary: #ececea; --primary-strong: #ffffff; --primary-fg: #1f1e1b; --primary-fg-dim: rgba(31, 30, 27, 0.72); --primary-fg-line: rgba(31, 30, 27, 0.35); --primary-soft: rgba(236, 236, 234, 0.09); + --ring: rgba(236, 236, 234, 0.35); +} +:root[data-theme='linen'] .nav-item:hover { background: rgba(255, 255, 255, 0.07); color: var(--fg); } +:root[data-theme='linen'] .nav-item.active { background: rgba(255, 255, 255, 0.11); color: var(--fg); } +:root[data-theme='linen'] .nav-item.active, :root[data-theme='linen'] .row.active { --primary-fg: var(--fg); --primary-fg-dim: var(--fg-2); --primary-fg-line: var(--border); } +:root[data-theme='linen'] .row.active { background: var(--secondary); color: var(--fg); } + +/* ⚠ 자동(@media) 블록은 위 라이트 오버라이드보다 **반드시 뒤에**(그래파이트와 같은 순서 계약). */ +@media (prefers-color-scheme: dark) { + :root[data-theme='linen'] { + color-scheme: dark; + --bg: #1f1e1b; + --card: #2a2926; + --card-2: #33322e; + --secondary: #3c3a35; + --fg: #e6e2d8; + --fg-2: #b3ada2; + --fg-3: #979185; + --border: rgba(230, 226, 216, 0.12); + --border-soft: rgba(230, 226, 216, 0.06); + --primary: #ececea; + --primary-strong: #ffffff; + --primary-fg: #1f1e1b; + --primary-fg-dim: rgba(31, 30, 27, 0.72); + --primary-fg-line: rgba(31, 30, 27, 0.35); + --primary-soft: rgba(236, 236, 234, 0.09); + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); + --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); + --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); + --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); + --ring: rgba(236, 236, 234, 0.35); + --selection: rgba(241, 238, 58, 0.28); + --overlay: rgba(0, 0, 0, 0.6); + --shadow-card: none; /* 그래파이트 규칙 계승 — 표면 단계로만 위계. 플로팅 컴포저·팝업만 --shadow-float */ + --shadow-float: none; + --accent: #ececea; --accent-rgb: 236, 236, 234; /* primary와 같은 값 — 계기판(Dial·Bars·meter)은 차콜. 옐로는 --mark로 분리 */ + --mark: #f1ee3a; --mark-rgb: 241, 238, 58; --mark-fg: #1f1e1b; /* 형광 옐로 — 점·노드·결재 머리띠·전송 버튼에만(면 채움은 그 둘뿐) */ + --mark-wash: var(--card); + --ink-rgb: 230, 226, 216; --paper-rgb: 42, 41, 38; + } + :root[data-theme='linen'] .side, :root[data-theme='linen'] .msgr-composer { + background: #171614; border-right-color: rgba(230, 226, 216, 0.1); color: #e6e2d8; + --bg: #171614; + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); + --fg: #e6e2d8; --fg-2: #a8a297; --fg-3: #8d877c; + --border: rgba(230, 226, 216, 0.12); --border-soft: rgba(230, 226, 216, 0.06); + --card: #2a2926; --card-2: #33322e; --secondary: #3c3a35; + --primary: #ececea; --primary-strong: #ffffff; --primary-fg: #1f1e1b; --primary-fg-dim: rgba(31, 30, 27, 0.72); --primary-fg-line: rgba(31, 30, 27, 0.35); --primary-soft: rgba(236, 236, 234, 0.09); + --ring: rgba(236, 236, 234, 0.35); +} + :root[data-theme='linen'] .nav-item:hover { background: rgba(255, 255, 255, 0.07); color: var(--fg); } + :root[data-theme='linen'] .nav-item.active { background: rgba(255, 255, 255, 0.11); color: var(--fg); } + :root[data-theme='linen'] .nav-item.active, :root[data-theme='linen'] .row.active { --primary-fg: var(--fg); --primary-fg-dim: var(--fg-2); --primary-fg-line: var(--border); } + :root[data-theme='linen'] .row.active { background: var(--secondary); color: var(--fg); } +} + +:root[data-theme='linen-light'] { + color-scheme: light; + --bg: #e9e6df; + --card: #fbfaf7; + --card-2: #f1efe9; + --secondary: #e0dcd3; + --fg: #26241f; + --fg-2: #5a564d; + --fg-3: #6b655e; + --border: rgba(38, 36, 31, 0.14); + --border-soft: rgba(38, 36, 31, 0.07); + --primary: #1f1e1b; + --primary-strong: #000000; + --primary-fg: #f4f2ec; + --primary-fg-dim: rgba(244, 242, 236, 0.72); + --primary-fg-line: rgba(244, 242, 236, 0.28); + --primary-soft: rgba(31, 30, 27, 0.07); + --ok: #3f7a52; --ok-soft: rgba(63, 122, 82, 0.12); + --info: #56688a; --info-soft: rgba(86, 104, 138, 0.12); + --warn: #9a6f1e; --warn-soft: rgba(154, 111, 30, 0.13); + --danger: #a8433a; --danger-soft: rgba(168, 67, 58, 0.11); + --ring: rgba(31, 30, 27, 0.32); + --selection: rgba(232, 228, 0, 0.35); + --overlay: rgba(31, 30, 27, 0.36); + --shadow-card: none; /* 그래파이트 규칙 계승 — 표면 단계로만 위계. 플로팅 컴포저·팝업만 --shadow-float */ + --shadow-float: 0 8px 24px rgba(31, 30, 27, 0.14); + --accent: #1f1e1b; --accent-rgb: 31, 30, 27; /* primary와 같은 값 — 계기판(Dial·Bars·meter)은 차콜. 옐로는 --mark로 분리 */ + --mark: #e8e400; --mark-rgb: 232, 228, 0; --mark-fg: #1f1e1b; /* 형광 옐로 — 점·노드·결재 머리띠·전송 버튼에만(면 채움은 그 둘뿐) */ + --mark-wash: color-mix(in srgb, var(--mark) 7%, var(--card)); + --ink-rgb: 38, 36, 31; --paper-rgb: 251, 250, 247; +} +:root[data-theme='linen-light'] .side, :root[data-theme='linen-light'] .msgr-composer { + background: #1f1e1b; border-right-color: rgba(230, 226, 216, 0.1); color: #e6e2d8; + --bg: #1f1e1b; /* 스코프 안 --bg = 레일 자기 배경(아바타 링·후광이 캔버스색으로 새지 않게 — 검수 H1) */ + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); /* 차콜 위 상태색은 다크 갈래 값 */ + --fg: #e6e2d8; --fg-2: #a8a297; --fg-3: #8d877c; + --border: rgba(230, 226, 216, 0.12); --border-soft: rgba(230, 226, 216, 0.06); + --card: #2a2926; --card-2: #33322e; --secondary: #3c3a35; + --primary: #ececea; --primary-strong: #ffffff; --primary-fg: #1f1e1b; --primary-fg-dim: rgba(31, 30, 27, 0.72); --primary-fg-line: rgba(31, 30, 27, 0.35); --primary-soft: rgba(236, 236, 234, 0.09); + --ring: rgba(236, 236, 234, 0.35); +} +:root[data-theme='linen-light'] .nav-item:hover { background: rgba(255, 255, 255, 0.07); color: var(--fg); } +:root[data-theme='linen-light'] .nav-item.active { background: rgba(255, 255, 255, 0.11); color: var(--fg); } +:root[data-theme='linen-light'] .nav-item.active, :root[data-theme='linen-light'] .row.active { --primary-fg: var(--fg); --primary-fg-dim: var(--fg-2); --primary-fg-line: var(--border); } +:root[data-theme='linen-light'] .row.active { background: var(--secondary); color: var(--fg); } + +:root[data-theme='linen-dark'] { + color-scheme: dark; + --bg: #1f1e1b; + --card: #2a2926; + --card-2: #33322e; + --secondary: #3c3a35; + --fg: #e6e2d8; + --fg-2: #b3ada2; + --fg-3: #979185; + --border: rgba(230, 226, 216, 0.12); + --border-soft: rgba(230, 226, 216, 0.06); + --primary: #ececea; + --primary-strong: #ffffff; + --primary-fg: #1f1e1b; + --primary-fg-dim: rgba(31, 30, 27, 0.72); + --primary-fg-line: rgba(31, 30, 27, 0.35); + --primary-soft: rgba(236, 236, 234, 0.09); + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); + --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); + --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); + --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); + --ring: rgba(236, 236, 234, 0.35); + --selection: rgba(241, 238, 58, 0.28); + --overlay: rgba(0, 0, 0, 0.6); + --shadow-card: none; /* 그래파이트 규칙 계승 — 표면 단계로만 위계. 플로팅 컴포저·팝업만 --shadow-float */ + --shadow-float: none; + --accent: #ececea; --accent-rgb: 236, 236, 234; /* primary와 같은 값 — 계기판(Dial·Bars·meter)은 차콜. 옐로는 --mark로 분리 */ + --mark: #f1ee3a; --mark-rgb: 241, 238, 58; --mark-fg: #1f1e1b; /* 형광 옐로 — 점·노드·결재 머리띠·전송 버튼에만(면 채움은 그 둘뿐) */ + --mark-wash: var(--card); + --ink-rgb: 230, 226, 216; --paper-rgb: 42, 41, 38; +} +:root[data-theme='linen-dark'] .side, :root[data-theme='linen-dark'] .msgr-composer { + background: #171614; border-right-color: rgba(230, 226, 216, 0.1); color: #e6e2d8; + --bg: #171614; + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.12); --info: #8fa3bd; --info-soft: rgba(143, 163, 189, 0.12); --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.13); --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.11); + --fg: #e6e2d8; --fg-2: #a8a297; --fg-3: #8d877c; + --border: rgba(230, 226, 216, 0.12); --border-soft: rgba(230, 226, 216, 0.06); + --card: #2a2926; --card-2: #33322e; --secondary: #3c3a35; + --primary: #ececea; --primary-strong: #ffffff; --primary-fg: #1f1e1b; --primary-fg-dim: rgba(31, 30, 27, 0.72); --primary-fg-line: rgba(31, 30, 27, 0.35); --primary-soft: rgba(236, 236, 234, 0.09); + --ring: rgba(236, 236, 234, 0.35); +} +:root[data-theme='linen-dark'] .nav-item:hover { background: rgba(255, 255, 255, 0.07); color: var(--fg); } +:root[data-theme='linen-dark'] .nav-item.active { background: rgba(255, 255, 255, 0.11); color: var(--fg); } +:root[data-theme='linen-dark'] .nav-item.active, :root[data-theme='linen-dark'] .row.active { --primary-fg: var(--fg); --primary-fg-dim: var(--fg-2); --primary-fg-line: var(--border); } +:root[data-theme='linen-dark'] .row.active { background: var(--secondary); color: var(--fg); } + +/* 내 말풍선은 차콜 반전 그대로(--primary) — 그래파이트처럼 표면으로 바꾸지 않는다(시안 v2: 내 글 = 척추 반대편 차콜). */ + * { margin: 0; padding: 0; box-sizing: border-box; } html { color-scheme: light; } diff --git a/app/i18n.jsx b/app/i18n.jsx index 19c72874..87cc61fa 100644 --- a/app/i18n.jsx +++ b/app/i18n.jsx @@ -184,10 +184,11 @@ const DICT = { 'settings.theme': ['테마', 'Theme'], 'settings.theme.desc': ['화면의 색과 질감을 선택합니다. 크루의 작업 방식에는 영향이 없습니다.', 'Choose the look of the interface. This does not affect how your crew works.'], 'settings.mode': ['모드', 'Appearance'], - 'settings.mode.desc': ['기본 테마(그래파이트 무채색 / 아르고 다크·골드)와 라이트·다크·시스템 자동을 고릅니다.', 'Pick the base theme (Graphite neutral / Argo dark · gold) and light, dark, or follow the system.'], + 'settings.mode.desc': ['기본 테마(그래파이트 무채색 / 아르고 다크·골드 / 리넨 웜 그레이지)와 라이트·다크·시스템 자동을 고릅니다.', 'Pick the base theme (Graphite neutral / Argo dark · gold / Linen warm greige) and light, dark, or follow the system.'], 'settings.family': ['기본 테마', 'Base theme'], 'settings.family.graphite': ['그래파이트', 'Graphite'], 'settings.family.argo': ['아르고', 'Argo'], + 'settings.family.linen': ['리넨', 'Linen'], 'settings.mode.system': ['시스템', 'System'], 'settings.mode.light': ['라이트', 'Light'], 'settings.mode.dark': ['다크', 'Dark'], @@ -217,6 +218,9 @@ const DICT = { 'settings.theme.graphite': ['그래파이트 — 무채색, 시스템 자동', 'Graphite — neutral gray, follows system'], 'settings.theme.graphite-light': ['그래파이트 — 무채색, 밝게 고정', 'Graphite — neutral gray, always light'], 'settings.theme.graphite-dark': ['그래파이트 — 무채색, 어둡게 고정', 'Graphite — neutral gray, always dark'], + 'settings.theme.linen': ['리넨 — 웜 그레이지·차콜 레일, 시스템 자동', 'Linen — warm greige, charcoal rail, follows system'], + 'settings.theme.linen-light': ['리넨 — 웜 그레이지, 밝게 고정', 'Linen — warm greige, always light'], + 'settings.theme.linen-dark': ['리넨 — 차콜, 어둡게 고정', 'Linen — charcoal, always dark'], 'settings.theme.apple-dark': ['Apple 다크 — 시스템 블랙', 'Apple Dark — system black'], 'settings.theme.glass': ['리퀴드 글래스 — Tahoe 반투명', 'Liquid Glass — Tahoe translucency'], 'settings.theme.glass-dark': ['리퀴드 글래스 다크 — 딥 블랙 유리', 'Liquid Glass Dark — deep black glass'], @@ -555,6 +559,42 @@ const DICT = { 'A crew direct bot is paired: approval requests are delivered to a paired bot, and briefings (routines, tasks, crew mail, inbox) go to the assigned crew’s bot or the default crew’s bot. This continues even while the gateway is stopped — use the notification chips above to turn kinds off. The company gateway is only needed for a separate dedicated bot.', ], // 커넥터(로그인만으로 붙는 외부 서비스) — 설계서 US-6 + // 팀 메신저 — 조직에 크루 등록(파견) 카드. 조직·채널·메시지는 메신저 앱이 다루고, 아르고 앱은 "내 크루를 어느 조직에 내보내나"만. + 'role.owner': ['소유자', 'Owner'], + 'role.admin': ['관리자', 'Admin'], + 'role.member': ['멤버', 'Member'], + 'role.guest': ['게스트', 'Guest'], + 'settings.msgr.title': ['팀 메신저에 크루 등록', 'Register crews to team messenger'], + 'settings.msgr.help': ['내 크루를 팀 메신저의 조직에 파견합니다. 조직 채널에서 @로 부르면 이 컴퓨터의 크루가 답합니다. 크루는 계속 이 회사 소속이고 열쇠도 이 컴퓨터에만 있습니다.', 'Send your crews to a team-messenger organization. When mentioned in an org channel, the crew answers from this computer. The crew stays in this company and its keys stay here.'], + 'settings.msgr.notSignedIn': ['이 기기가 아르고 계정에 로그인되어 있지 않습니다. 설정의 기기 카드에서 로그인하세요.', 'This device is not signed in to an Argo account. Sign in from the Devices card.'], + 'settings.msgr.noOrg': ['가입한 조직이 없습니다. 팀 메신저 앱에서 조직을 만들거나 초대 링크로 들어온 뒤 다시 여세요.', 'You are not in any organization yet. Create one or join with an invite link in the team messenger, then come back.'], + 'settings.msgr.org': ['조직', 'Organization'], + 'settings.msgr.registered': ['파견 중', 'Registered'], + 'settings.msgr.notRegistered': ['미등록', 'Not registered'], + 'settings.msgr.register': ['등록', 'Register'], + 'settings.msgr.unregister': ['해제', 'Unregister'], + 'settings.msgr.allow': ['누가 시킬 수 있나', 'Who can task it'], + 'settings.msgr.allow.all': ['조직 전원', 'Everyone'], + 'settings.msgr.allow.list': ['지정 멤버', 'Selected members'], + 'settings.msgr.allow.owner': ['나만', 'Only me'], + 'settings.msgr.allow.pick': ['허용할 멤버', 'Allowed members'], + 'settings.msgr.allow.locked': ['조직 정책으로 고정된 값입니다 — 관리자가 메신저 설정 › 조직 정책에서 바꿉니다.', 'Fixed by organization policy — admins change it under the messenger’s Settings › Organization policy.'], + 'settings.msgr.allow.noMembers': ['아직 다른 멤버가 없습니다.', 'No other members yet.'], + 'settings.msgr.policyNote': ['결재 확정과 허용 범위 기본값은 조직 정책이 정합니다. 여기서는 정책이 허용한 범위 안에서만 바꿀 수 있습니다.', 'Approval authority and the default access scope are set by organization policy. You can only adjust within what the policy allows.'], + 'settings.msgr.policy': ['조직 정책', 'Organization policy'], + 'settings.msgr.policy.summary': ['허용 범위 기본값 {allow} · 고위험 결재 {approver} · 채널 크루 기억 {memory}. 잠긴 항목은 관리자가 메신저 설정에서 바꿉니다.', 'Default access {allow} · high-risk approvals by {approver} · channel crew memory {memory}. Locked items are changed by admins in the messenger settings.'], + 'settings.msgr.policy.locked': ['(잠김)', ' (locked)'], + 'settings.msgr.policy.approver.admin': ['조직 관리자', 'org admins'], + 'settings.msgr.policy.approver.owner': ['크루 소유자', 'the crew owner'], + 'settings.msgr.policy.memory.on': ['남김', 'on'], + 'settings.msgr.policy.memory.off': ['안 남김', 'off'], + 'settings.msgr.tierNote': ['파견한 크루는 조직에서 "개인 크루"로 보입니다. 조직은 이 컴퓨터의 실행 환경(모델·로컬 기록)을 통제하지 않으며, 서버가 검증하는 항목(허용 범위·답글·결재권·채널 정책)만 강제됩니다. 조직이 완전히 통제하는 크루는 회사 노드의 회사 크루입니다.', 'Dispatched crews appear as "personal crews" in the organization. The organization does not control this computer’s runtime (model, local records); only server-verified items (access, replies, approval rights, channel policy) are enforced. Crews the organization fully controls are company crews on the company node.'], + 'settings.msgr.bridge.on': ['브리지 켜짐 — 이 컴퓨터가 조직 채널을 지켜봅니다', 'Bridge on — this computer watches the org channels'], + 'settings.msgr.bridge.onShort': ['브리지 켜짐', 'Bridge on'], + 'settings.msgr.bridge.offShort': ['브리지 꺼짐', 'Bridge off'], + 'settings.msgr.bridge.off': ['브리지 꺼짐 — 등록된 크루가 없습니다', 'Bridge off — no crews registered'], + 'settings.msgr.noCrews': ['먼저 크루를 영입하세요.', 'Hire a crew first.'], + 'settings.msgr.err.load': ['조직·등록 상태를 불러오지 못했습니다.', 'Could not load organizations and registrations.'], 'settings.connectors.title': ['외부 서비스 연결', 'Connected services'], 'settings.connectors.help': ['구글 계정으로 로그인하면 크루가 그 서비스를 씁니다. API 키를 만들거나 붙여넣을 필요가 없습니다.', 'Sign in with your Google account and your crew can use that service. No API keys to create or paste.'], 'settings.connectors.empty': ['아직 연결할 수 있는 서비스가 없습니다.', 'No services available to connect yet.'], @@ -571,6 +611,7 @@ const DICT = { 'settings.connectors.status.reauth': ['재연결 필요', 'Reconnect needed'], 'settings.connectors.status.error': ['오류', 'Error'], 'settings.conn.ev.inbox': ['받은 서류함 처리 결과', 'Inbox file results'], + 'settings.conn.ev.delegate': ['위임 결과 미러(크루 간)', 'Delegation results (crew to crew)'], // 팀 메신저(msgr) 채널 종류 — channel-events.mjs와 짝 'settings.saved': ['저장됨', 'Saved'], 'settings.save': ['저장', 'Save'], 'settings.nameplate.runtime': ['가동 환경', 'Runtime'], diff --git a/app/theme.jsx b/app/theme.jsx index 4a4658b4..32252a2f 100644 --- a/app/theme.jsx +++ b/app/theme.jsx @@ -12,6 +12,8 @@ export const THEMES = [ 'codex-gh-light', 'codex-gh-dark', 'enjoyer', 'minimal-light', 'minimal-dark', // 중성 회색(유건 지정 2026-08-01). 'graphite'는 **시스템 자동** — argo와 같은 방식으로 다크/라이트를 따라간다. 'graphite', 'graphite-light', 'graphite-dark', + // 웜 뉴트럴(메신저 시안 2026-09-03 승인). 'linen'은 시스템 자동, 나머지는 고정. 메신저 앱의 기본값. + 'linen', 'linen-light', 'linen-dark', ]; // 기본값 — 그래파이트(시스템 자동). 유건 지시 2026-08-23: 기본 테마를 그래파이트로, 아르고는 선택 가능하게 유지. // 'argo'만 data-theme 없이(:root 토큰) 렌더되므로 기본값이 바뀌어도 argo 분기는 그대로 둔다. @@ -28,13 +30,14 @@ function apply(theme) { const ThemeCtx = createContext(null); -export function ThemeProvider({ children }) { - const [theme, setThemeState] = useState(DEFAULT_THEME); +// defaultTheme — 앱별 첫 페인트 기본값(메신저는 'linen'). 저장값이 있으면 그것이 이긴다. Argo 앱은 인자 없이 DEFAULT_THEME. +export function ThemeProvider({ children, defaultTheme = DEFAULT_THEME }) { + const [theme, setThemeState] = useState(defaultTheme); useEffect(() => { const saved = localStorage.getItem(KEY); - if (THEMES.includes(saved)) { setThemeState(saved); apply(saved); } else apply(DEFAULT_THEME); - }, []); + if (THEMES.includes(saved)) { setThemeState(saved); apply(saved); } else apply(defaultTheme); + }, [defaultTheme]); const setTheme = useCallback((next) => { if (!THEMES.includes(next)) return; diff --git a/apps/messenger/.gitignore b/apps/messenger/.gitignore new file mode 100644 index 00000000..bac3cc42 --- /dev/null +++ b/apps/messenger/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.env.local +src-tauri/target +src-tauri/gen/schemas diff --git a/apps/messenger/design/01-channel-v2.html b/apps/messenger/design/01-channel-v2.html new file mode 100644 index 00000000..50b94b6a --- /dev/null +++ b/apps/messenger/design/01-channel-v2.html @@ -0,0 +1,22 @@ +linen v2 · 채널 대화
general전체 공지와 잡담 — 오늘 오후 3시 캠페인 리뷰
9월 3일 수요일
민수14:02
다음 주 캠페인 브리프 초안 공유합니다. 온보딩 7일 플로우를 메인으로 잡았고, 추천 리워드는 2안으로 뒀어요. @서윤 검토 부탁해요.
서윤마케터 · 크루14:03
민수: 다음 주 캠페인 브리프 초안 공유합니다…

브리프 확인했습니다. 핵심 메시지 3개로 압축해 정리하겠습니다.

  1. 신규 고객 온보딩 7일 — 첫 주 이탈 구간을 메시지로 메운다
  2. 추천 리워드 — 기존 고객의 소개를 한 번 더 자극
  3. 팀 플랜 출시 — 개인에서 조직으로 넘어가는 이유를 준다

오늘 오후 3시 리뷰 전에 카피 초안까지 올리겠습니다.

@서윤 2번은 리워드 금액 넣지 말고 "소개하면 한 달 무료"로 표현만 잡아줘. 금액은 지아가 정한 뒤에.
14:05
지아14:06
리워드 금액은 내일 오전까지 확정해서 올릴게요. 그 전엔 문구만 잡는 걸로 👍
새 글
서윤마케터 · 크루14:07

알겠습니다. 2번은 "소개하면 한 달 무료"로 잡고 금액은 비워 두겠습니다. 카피 초안 3종을 올렸어요. files/캠페인-카피-v1.md

서윤마케터 · 크루14:12
결재 요청ap-7f2k
뉴스레터 3,200명에게 발송
캠페인 D-7 안내. 발송 리스트는 files/뉴스레터-9월.csv, 제목 A안 사용.
서윤마케터 · 크루지금
서윤 입력 중…enter 전송 · shift+enter 줄바꿈
\ No newline at end of file diff --git a/apps/messenger/design/02-approval-v2.html b/apps/messenger/design/02-approval-v2.html new file mode 100644 index 00000000..77aafe93 --- /dev/null +++ b/apps/messenger/design/02-approval-v2.html @@ -0,0 +1,22 @@ +linen v2 · 결재
general전체 공지와 잡담 — 오늘 오후 3시 캠페인 리뷰
9월 1일 월요일
데이터 분석 · 크루월요일
만료ap-09d2
외부 API 키 재발급 요청
24시간 내 결정 없음
9월 2일 화요일
서윤마케터 · 크루어제
거절ap-1b77
인스타그램 광고 예산 30만원 집행
사유: 리워드 금액 확정 전 — 다음 주에 다시 올려 주세요.
유건 · 어제 17:20
9월 3일 수요일
데이터 분석 · 크루11:40
승인ap-3c9a
지난주 전환 리포트를 #marketing에 공유
유건 · 11:52실행 완료 · 11:58
데이터 분석 · 크루14:10
승인 · 실행 중ap-5e10
#marketing에 전환 리포트 초안 게시
유건 · 14:10준이 이어서 실행하는 중 — 끝나면 이 카드가 닫힙니다
서윤마케터 · 크루14:12
결재 요청ap-7f2k
뉴스레터 3,200명에게 발송
캠페인 D-7 안내. 발송 리스트는 files/뉴스레터-9월.csv, 제목 A안 사용.
@서윤 발송 리스트에서 지난달 해지 고객은 빼고 다시 올려줘.
14:14
서윤 입력 중…enter 전송 · shift+enter 줄바꿈
\ No newline at end of file diff --git a/apps/messenger/design/03-empty-v2.html b/apps/messenger/design/03-empty-v2.html new file mode 100644 index 00000000..15365fdf --- /dev/null +++ b/apps/messenger/design/03-empty-v2.html @@ -0,0 +1,22 @@ +linen v2 · 빈 상태
린 컴퍼니조직을 만들었습니다 — 아직 채널이 없습니다
시작하기

사람과 크루가 같은 채널에서 일하는 공간입니다.

세 단계면 첫 대화가 시작됩니다. 크루는 각자의 컴퓨터에서 자기 열쇠로 돌고, 채널에는 답만 올라옵니다.

1
첫 채널 만들기#general 같은 공개 채널 하나면 충분합니다
2
동료 초대초대 링크는 7일 동안 유효하고, 좌석 3개까지 무료입니다
3
크루 등록아르고 앱 → 설정 → "조직에 크루 등록"에서 누가 시킬 수 있는지 고릅니다
\ No newline at end of file diff --git a/apps/messenger/design/04-auth-v2.html b/apps/messenger/design/04-auth-v2.html new file mode 100644 index 00000000..39e6de2b --- /dev/null +++ b/apps/messenger/design/04-auth-v2.html @@ -0,0 +1,22 @@ +linen v2 · 로그인
ARGO팀 메신저

팀 공간으로 들어갑니다.

아르고 계정 이메일로 로그인 코드를 보내드립니다. 비밀번호는 없습니다.

초대 링크로 오셨다면 코드 확인 뒤 자동으로 조직에 들어갑니다.
\ No newline at end of file diff --git a/apps/messenger/design/05-states-v2.html b/apps/messenger/design/05-states-v2.html new file mode 100644 index 00000000..b09f7eac --- /dev/null +++ b/apps/messenger/design/05-states-v2.html @@ -0,0 +1,22 @@ +linen v2 · 상태 모음
general전체 공지와 잡담 — 오늘 오후 3시 캠페인 리뷰
상태 모음 · 보는 사람: 민수(멤버)
지아지금
서윤마케터 · 크루14:12
결재 요청 · 유건의 결정 대기ap-7f2k
뉴스레터 3,200명에게 발송
캠페인 D-7 안내. 발송 리스트는 files/뉴스레터-9월.csv, 제목 A안 사용.
이 크루의 소유자만 확정합니다. 결정되면 여기 표시됩니다.
@서윤 발송 리스트에서 지난달 해지 고객은 빼고 다시 올려줘.
전송 중…
회의록 링크도 같이 넣어줘.
전송 실패
민수14:15
이 메시지는 삭제되었습니다.
준은 부재중입니다(마지막 접속 42분 전). 돌아오면 이 지시부터 처리합니다.
서윤 입력 중…enter 전송 · shift+enter 줄바꿈
\ No newline at end of file diff --git a/apps/messenger/design/README.md b/apps/messenger/design/README.md new file mode 100644 index 00000000..615cd9f2 --- /dev/null +++ b/apps/messenger/design/README.md @@ -0,0 +1,57 @@ +# linen — 아르고 메신저 디자인 시안·시스템 + +> 메신저 앱(`apps/messenger`)의 룩 정본. 토큰은 `tokens.css` 한 파일, v2 컴포넌트 계약은 `system-v2.css`. 정적 시안은 `0*-v2.html` 5장. 스크린샷(`shots/`)은 로컬 실측 산출물이라 커밋하지 않는다. +> Argo 앱의 `app/globals.css` linen 테마 가족은 **이 폴더의 tokens.css에서 생성**한다(손으로 옮겨 적지 않는다 — 두 곳이 갈리면 `test/theme-linen-sync`가 잡는다). + +## 보는 법 + +``` +cd apps/messenger && npx vite --port 5183 +open http://localhost:5183/design/01-channel-v2.html # 라이트 +open http://localhost:5183/design/01-channel-v2.html?theme=linen-dark +``` + +| 파일 | 화면 | +|---|---| +| `01-channel-v2.html` | 채널 대화(소유자 시점) — 사람 메시지(캔버스 위 텍스트)·크루 시트·내 글 차콜 버블·날짜 노드·파일 칩·타이핑·2단 독 | +| `02-approval-v2.html` | 결재 슬립 — 만료·거절·승인·승인 실행 중·요청(시간순) | +| `03-empty-v2.html` | 빈 상태 — 척추 위 3단계 노드 | +| `04-auth-v2.html` | 로그인 — 머리띠 카드 | +| `05-states-v2.html` | 상태 모음(멤버 시점) — 로딩·결재 대기(비소유자)·전송 중·실패·삭제됨·부재중 | + +## 무드와 모티프 + +웜 그레이지 캔버스 위 순백 카드, 왼쪽은 **라이트에서도 차콜인 레일**, 형광 옐로는 점·노드·결재 요청 머리띠·전송 버튼에만(면 채움은 머리띠와 전송 둘뿐). 그림자 대신 명도·재질 3단으로 위계. + +| 모티프 | 규칙 | +|---|---| +| 타임라인 척추 | 스레드 왼쪽 2px 선(`--border`) 위에 아바타·날짜 노드가 앉는다. 아바타는 4px 캔버스 링으로 선을 끊는다. 크루 시트는 좌상 반경 6px, 내 글은 우상 6px | +| 사람 원 / 크루 타일 | 사람 = 28px 원, 크루 = 28px 둥근 사각(r 9) + 옐로 별 배지. 레일에서 크루는 40px 타일 카드, 채널은 칩 랩(6개 초과 `+N`), 멤버는 겹친 아바타 스택 | +| 2단 다크 독 | 입력 줄 + 도구 줄(첨부·멘션 │ 상태) + 옐로 원형 전송. `.msgr-composer`는 linen 차콜 스코프(globals.css) | +| 결재 슬립 | 머리띠(요청=옐로 / 확정=차콜 / 만료=회색 / 비소유자=연회색+옐로 밑줄) + 본문 + 도장 실(체크=승인, 엑스=거절, 시계=만료) | + +아이콘: 16 그리드 · 1.5px 스트로크 · 둥근 끝의 자체 스프라이트(`src/icons.jsx`). 한글 마이크로라벨은 Pretendard 11px(`.msgr-klabel`), 모노는 id·시각·카운트에만. + +## 토큰 (tokens.css 정본) + +| 토큰 | 라이트 | 다크 | 비고 | +|---|---|---|---| +| `--bg` | `#e9e6df` | `#1f1e1b` | 캔버스 | +| `--card` · `--card-2` · `--secondary` | `#fbfaf7` · `#f1efe9` · `#e0dcd3` | `#2a2926` · `#33322e` · `#3c3a35` | 표면 3단 | +| `--fg` · `--fg-2` · `--fg-3` | `#26241f` · `#5a564d` · `#6b655e` | `#e6e2d8` · `#b3ada2` · `#979185` | 본문 12.4+ · 보조 5.9+ · 최약 4.6+ | +| `--primary` / `--primary-fg` | `#1f1e1b` / `#f4f2ec` | `#ececea` / `#1f1e1b` | 반전 버튼·내 글 | +| `--accent` | `= --primary` | `= --primary` | 계기판은 차콜(옐로 아님) | +| `--mark` / `--mark-fg` | `#e8e400` / `#1f1e1b` | `#f1ee3a` / `#1f1e1b` | 표시 색 — 점·노드·머리띠·전송 | +| 레일 스코프(`.side`·`.msgr-composer`) | 배경 `#1f1e1b`, 글자 `#e6e2d8 / #a8a297 / #8d877c`, 상태색은 다크 갈래 | 배경 `#171614` | 스코프 안 `--bg` = 레일 배경 | +| `--shadow-card` / `--shadow-float` | none / `0 8px 24px rgba(31,30,27,.14)` | none / none | 플로팅 독·팝업만 | + +## 실측 방법 + +- 데스크톱: Aside로 1440×900 라이트/다크 스크린샷, `scrollWidth == innerWidth`, `right > innerWidth` 요소 0, console/pageerror 0. +- 폰 폭: 같은 오리진 페이지에 390×844 iframe을 심어 `matchMedia('(max-width:720px)')`가 실제로 참인 상태에서 클립 스크린샷(`html.style.zoom`은 미디어쿼리를 움직이지 않는다). +- 대비: WCAG 상대 휘도로 토큰 쌍 재계산(본문 ≥ 10, 보조 ≥ 6, 최약 ≥ 4.5). + +## Argo 앱 접점 + +`app/globals.css`의 `linen` / `@media dark` / `linen-light` / `linen-dark` 4블록, `app/theme.jsx THEMES`, `app/i18n.jsx settings.family.linen·settings.theme.linen*`, 설정 `FAMILIES`·`THEME_SWATCHES`. 메신저는 `ThemeProvider defaultTheme="linen"`(`src/main.jsx`)과 `index.html` 부트 폴백이 같은 값이어야 한다(`test/theme-boot-default`). +시안에만 있고 앱에 없는 것: 도구 줄의 명령·폴더·회의 버튼, "승인 · 실행 중" 상태, 전송 중/실패 낙관 상태. diff --git a/apps/messenger/design/system-v2.css b/apps/messenger/design/system-v2.css new file mode 100644 index 00000000..74258409 --- /dev/null +++ b/apps/messenger/design/system-v2.css @@ -0,0 +1,238 @@ +/* linen v2 — 형태 층 재설계(2026-09-03, 유건 교정 "컬러만 바뀌었다" 반영). tokens.css 그대로, 골격만 새로. + 모티프 4개: ① 타임라인 척추(스레드 왼쪽 2px 선 위에 아바타·날짜 노드가 앉는다) ② 크루 = 타일(둥근 사각 + 별 배지) / 사람 = 원 + ③ 2단 다크 독(입력 줄 + 도구 줄) ④ 결재 슬립(머리띠 + 도장 실) . 아이콘은 16 그리드 1.5px 스트로크 자체 세트(sprite). */ +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { background: var(--bg); color: var(--fg); font: 13px/1.5 var(--font); letter-spacing: -0.005em; -webkit-font-smoothing: antialiased; word-break: keep-all; } +button, input, textarea { font: inherit; color: inherit; } +button { background: none; border: 0; cursor: pointer; padding: 0; } +* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } +::selection { background: var(--selection); } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } +.ic { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.ic.s { width: 14px; height: 14px; } +.klabel { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; color: var(--fg-3); } +.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } +.dot { width: 8px; height: 8px; border-radius: 999px; background: var(--fg-3); flex: none; display: inline-block; } +.dot.mark { background: var(--mark); } +.dot.ok { background: var(--ok); } + +/* ── 아바타: 사람 = 원 / 크루 = 타일(r 9) + 별 배지 ── */ +.av { width: 28px; height: 28px; border-radius: 999px; flex: none; display: grid; place-items: center; font-weight: 600; font-size: 11.5px; color: var(--fg); background: var(--card); border: 1px solid var(--border); position: relative; } +.av.crew { border-radius: 9px; background: var(--primary); color: var(--primary-fg); border-color: var(--primary); } +.av.crew .star { position: absolute; right: -6px; top: -6px; width: 15px; height: 15px; border-radius: 999px; background: var(--mark); border: 2px solid var(--bg); display: grid; place-items: center; z-index: 1; } +.av.crew .star svg { width: 8px; height: 8px; fill: var(--mark-fg); stroke: none; } +.av.lg { width: 40px; height: 40px; font-size: 14px; } +.av.lg.crew { border-radius: 12px; } +.av.sm { width: 22px; height: 22px; font-size: 10px; } + +/* ── 버튼 ── */ +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 32px; padding: 0 14px; border-radius: 999px; border: 1px solid var(--border); background: var(--card); font-weight: 500; font-size: 12.5px; white-space: nowrap; transition: background 120ms ease, transform 120ms ease; } +.btn:hover { background: var(--card-2); } +.btn:active { transform: scale(0.97); } +.btn-primary { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); font-weight: 600; } +.btn-primary:hover { background: var(--primary-strong); } +.btn.sm { height: 28px; padding: 0 12px; font-size: 12px; } + +/* ── 셸 ── */ +.shell { display: grid; grid-template-columns: 268px minmax(0, 1fr); height: 100vh; } +.side { background: var(--dark-bg); color: var(--dark-fg); display: flex; flex-direction: column; padding: 16px 14px 14px; gap: 4px; height: 100vh; overflow-y: auto; border-right: 1px solid var(--dark-line); } +.side .brand { display: flex; align-items: center; gap: 8px; padding: 2px 6px 12px; font-family: var(--mono); font-weight: 600; letter-spacing: 0.16em; font-size: 12px; } +.side .brand svg { width: 14px; height: 14px; fill: var(--mark); } +.org { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 8px 0 6px; border-radius: 14px; background: var(--dark-bg-2); border: 1px solid var(--dark-line); font-weight: 600; font-size: 13.5px; } +.org .av { background: var(--dark-fg); color: var(--dark-bg); border-color: var(--dark-fg); border-radius: 9px; width: 30px; height: 30px; } /* 옐로 면은 '행동'(결재 머리띠·전송)에만 — 조직 타일은 차콜 반전 */ +.org .ic { margin-left: auto; color: var(--dark-fg-3); } +.side .group { display: flex; align-items: center; justify-content: space-between; padding: 0 6px; margin: 18px 0 8px; font-size: 11px; font-weight: 500; letter-spacing: 0.02em; color: var(--dark-fg-3); } +.side .group button { width: 22px; height: 22px; border-radius: 7px; color: var(--dark-fg-3); display: grid; place-items: center; } +.side .group button:hover { background: var(--dark-hover); color: var(--dark-fg); } +/* 채널 = 칩 랩(목록 행이 아니라) */ +.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 2px; } +.chan { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px 0 10px; border-radius: 999px; border: 1px solid var(--dark-line); color: var(--dark-fg-2); font-size: 12.5px; font-weight: 500; transition: background 120ms ease, color 120ms ease; } +.chan .ic { width: 13px; height: 13px; color: var(--dark-fg-3); } +.chan:hover { background: var(--dark-hover); color: var(--dark-fg); } +.chan.active { background: var(--dark-fg); color: var(--dark-bg); border-color: var(--dark-fg); } +.chan.active .ic { color: var(--dark-bg); } +.chan.more { border-style: dashed; color: var(--dark-fg-3); } +.chan .n { font-family: var(--mono); font-size: 10px; background: var(--mark); color: var(--mark-fg); border-radius: 999px; padding: 1px 6px; margin-left: 2px; } +/* 크루 = 카드 타일 */ +.crewcard { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: 12px; align-items: center; padding: 10px; border-radius: 14px; background: var(--dark-bg-2); border: 1px solid var(--dark-line); transition: background 120ms ease; } +.crewcard + .crewcard { margin-top: 6px; } +.crewcard:hover { background: var(--dark-hover); } +.crewcard .av.crew { background: var(--dark-fg); color: var(--dark-bg); border-color: var(--dark-fg); } +.crewcard .av.crew .star { border-color: var(--dark-bg-2); } +.crewcard .name { font-weight: 600; color: var(--dark-fg); display: flex; align-items: center; gap: 8px; } +.crewcard .name .st { font-size: 10.5px; font-weight: 500; color: var(--mark); margin-left: auto; display: inline-flex; align-items: center; gap: 5px; } +.crewcard .name .st.off { color: var(--dark-fg-3); } +.crewcard .name .st .dot { width: 6px; height: 6px; } +.crewcard .sub { font-size: 11px; color: var(--dark-fg-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +/* 멤버 = 아바타 스택 한 줄 */ +.stack { display: flex; align-items: center; padding: 0 6px; } +.stack .av { margin-left: -4px; background: var(--dark-bg-2); border-color: var(--dark-bg); color: var(--dark-fg); } +.stack .av:first-child { margin-left: 0; } +.stack .more { margin-left: 10px; font-size: 11.5px; color: var(--dark-fg-3); } +.side .foot { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 12px 6px 0; border-top: 1px solid var(--dark-line); color: var(--dark-fg-2); font-size: 12.5px; } +.side .foot .av { background: var(--dark-bg-2); border-color: var(--dark-line); color: var(--dark-fg); } +.side .foot .gear { margin-left: auto; width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; color: var(--dark-fg-3); } +.side .foot .gear:hover { background: var(--dark-hover); color: var(--dark-fg); } + +/* ── 본문 ── */ +.main { display: flex; flex-direction: column; min-width: 0; min-height: 0; height: 100vh; } +.top { display: flex; align-items: center; gap: 14px; padding: 14px 24px 12px; border-bottom: 1px solid var(--border); background: var(--bg); } +.top .title { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 600; letter-spacing: -0.02em; } +.top .title .ic { width: 18px; height: 18px; color: var(--fg-3); } +.top .topic { font-size: 12.5px; color: var(--fg-3); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.top .members { display: flex; align-items: center; margin-left: auto; } +.top .members .av { margin-left: -4px; border-color: var(--bg); } +.top .members .av:first-child { margin-left: 0; } +.seg { display: flex; gap: 2px; padding: 3px; border-radius: 999px; background: var(--card-2); border: 1px solid var(--border-soft); } +.seg button { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border-radius: 999px; font-size: 12px; font-weight: 500; color: var(--fg-2); transition: background 120ms ease, color 120ms ease; } +.seg button .ic { width: 13px; height: 13px; } +.seg button.active { background: var(--primary); color: var(--primary-fg); font-weight: 600; } +.seg button .n { font-family: var(--mono); font-size: 10.5px; opacity: .8; } +.thread-wrap { flex: 1; min-height: 0; overflow-y: auto; scrollbar-gutter: stable both-edges; padding: 20px max(24px, calc((100% - 720px) / 2)) 8px; } +/* 타임라인 척추 */ +.thread { position: relative; display: flex; flex-direction: column; gap: 20px; padding-left: 0; } +.thread::before { content: ''; position: absolute; left: 13px; top: 8px; bottom: 8px; width: 2px; background: var(--border); border-radius: 2px; } /* 평가: border-soft는 캔버스 대비 1.14로 지워짐 → border(14%) */ +.tnode { display: flex; align-items: center; gap: 12px; position: relative; } +.tnode .dot { width: 10px; height: 10px; margin-left: 9px; box-shadow: 0 0 0 4px var(--bg); } +.tnode .klabel { color: var(--fg-2); } +.tnode .klabel b { font-weight: 600; color: var(--fg); } +.row { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 14px; position: relative; animation: rise 250ms cubic-bezier(.23,1,.32,1) both; } +@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +.row .av { box-shadow: 0 0 0 4px var(--bg); } +.row .who { display: flex; align-items: baseline; gap: 8px; font-size: 12.5px; font-weight: 600; margin: 5px 0 4px; } +.row .who .role { font-size: 11px; font-weight: 400; color: var(--fg-3); } +.row .who .ts { font-family: var(--mono); font-size: 10px; font-weight: 500; color: var(--fg-3); } +.row .text { font-size: 13.5px; line-height: 1.6; max-width: 640px; } +/* 크루 답 = 척추에 붙는 카드(좌상 반경 작게) */ +.row .sheet { background: var(--card); border: 1px solid var(--border); border-radius: 6px 20px 20px 20px; padding: 13px 16px; font-size: 13.5px; line-height: 1.6; max-width: 640px; } +.row .sheet p { margin: 0 0 .5em; } .row .sheet p:last-child { margin: 0; } +.row .sheet ol { margin: .4em 0 .5em; padding-left: 1.3em; } .row .sheet li { margin: .15em 0; } +.row .sheet .quote { display: flex; gap: 8px; align-items: center; color: var(--fg-3); font-size: 12px; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.row .sheet .quote .ic { width: 13px; height: 13px; } +.row .acts { display: flex; gap: 4px; margin-top: 6px; opacity: 0; transition: opacity 150ms ease; } +.row:hover .acts, .row:focus-within .acts { opacity: 1; } +.row .acts button { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-3); padding: 3px 8px; border-radius: 999px; border: 1px solid transparent; transition: background 120ms ease, color 120ms ease; } +.row .acts button .ic { width: 12px; height: 12px; } +.row .acts button:hover { background: var(--card); border-color: var(--border); color: var(--fg); } +.file { display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px 5px 7px; border-radius: 10px; background: var(--card-2); border: 1px solid var(--border-soft); font-family: var(--mono); font-size: 11px; color: var(--fg-2); vertical-align: middle; } +.file .ic { width: 13px; height: 13px; color: var(--fg-3); } +/* 내 글 = 척추 오른쪽 끝, 차콜 20/4/20/20 */ +.mine { grid-column: 1 / -1; display: grid; justify-items: end; gap: 4px; margin-left: 42px; } +.mine .bubble { max-width: min(88%, 620px); background: var(--primary); color: var(--primary-fg); border-radius: 20px 6px 20px 20px; padding: 10px 15px; font-size: 13.5px; line-height: 1.55; overflow-wrap: anywhere; } +.mine .meta { font-size: 11px; color: var(--fg-3); display: flex; gap: 8px; align-items: center; } +.mine .meta .ic { width: 12px; height: 12px; color: var(--ok); } +.mention { white-space: nowrap; font-weight: 600; } +.unread { display: flex; align-items: center; gap: 10px; margin-left: 42px; color: var(--mark-fg); font-size: 11px; font-weight: 600; } +.unread::before { content: ''; flex: 1; border-top: 1.5px solid var(--mark); } +.unread span { background: var(--mark); border-radius: 999px; padding: 2px 9px; } +.typing { display: inline-flex; gap: 4px; padding: 11px 13px; background: var(--card); border: 1px solid var(--border); border-radius: 6px 16px 16px 16px; } +.typing i { width: 5px; height: 5px; border-radius: 999px; background: var(--fg-3); animation: pulse 1.2s ease-in-out infinite; } +.typing i:nth-child(2) { animation-delay: .15s; } .typing i:nth-child(3) { animation-delay: .3s; } +@keyframes pulse { 0%, 100% { opacity: .3; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-2px); } } + +/* 결재 슬립 — 머리띠(차콜) + 본문 + 도장 실 */ +.slip { max-width: 640px; border-radius: 6px 20px 20px 20px; overflow: hidden; border: 1px solid var(--border); background: var(--card); } +.slip .band { display: flex; align-items: center; gap: 10px; padding: 9px 14px 9px 12px; background: var(--primary); color: var(--primary-fg); font-size: 11.5px; font-weight: 600; } +.slip .band .ic { width: 14px; height: 14px; } +.slip .band .id { margin-left: auto; font-family: var(--mono); font-size: 10.5px; font-weight: 500; opacity: .7; } +.slip.pending .band { background: var(--mark); color: var(--mark-fg); } +.slip .body { padding: 13px 16px 14px; display: grid; gap: 8px; } +.slip .action { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; } +.slip .reason { font-size: 12.5px; color: var(--fg-2); } +.slip .row2 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 4px; } +.seal { display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px 4px 5px; border-radius: 999px; border: 1.5px solid var(--fg-2); color: var(--fg-2); font-size: 11.5px; font-weight: 600; } +.seal .ic { width: 18px; height: 18px; padding: 3px; border-radius: 999px; background: var(--fg-2); color: var(--card); stroke-width: 2.2; } +.seal.ok { border-color: var(--ok); color: var(--ok); } .seal.ok .ic { background: var(--ok); } +.slip.done .action { color: var(--fg-2); font-weight: 500; } +.slip.rejected .action { text-decoration: line-through; text-decoration-color: var(--fg-3); } +.seal.no { border-color: var(--danger); color: var(--danger); } .seal.no .ic { background: var(--danger); } +.slip .done-line { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--fg-2); } .slip .done-line .dot { width: 6px; height: 6px; } +.slip.expired .band { background: var(--card-2); color: var(--fg-3); } +.slip.expired .action { color: var(--fg-3); } + +/* 2단 다크 독 */ +.dock { padding: 14px max(24px, calc((100% - 720px) / 2)) 16px; overflow-y: auto; scrollbar-gutter: stable both-edges; position: sticky; bottom: 0; background: linear-gradient(to top, var(--bg) 70%, transparent); } +.composer { background: var(--dark-bg); color: var(--dark-fg); border-radius: 22px; padding: 12px 12px 10px 16px; display: grid; gap: 8px; box-shadow: var(--shadow-float); border: 1px solid var(--dark-line); transition: box-shadow 150ms ease; } +.composer:focus-within { box-shadow: var(--shadow-float), 0 0 0 2px rgba(var(--mark-rgb), .45); } +.composer textarea { width: 100%; min-height: 44px; max-height: 200px; resize: none; background: transparent; border: 0; outline: 0; color: var(--dark-fg); font-size: 13.5px; line-height: 1.55; padding: 4px 0 0; } +.composer textarea::placeholder { color: var(--dark-fg-3); } +.composer .tools { display: flex; align-items: center; gap: 2px; } +.composer .tools .tb { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 9px; border-radius: 999px; color: var(--dark-fg-2); font-size: 12px; transition: background 120ms ease, color 120ms ease; } +.composer .tools .tb:hover { background: var(--dark-hover); color: var(--dark-fg); } +.composer .tools .tb.on { background: var(--dark-active); color: var(--dark-fg); } +.composer .tools .sep { width: 1px; height: 16px; background: var(--dark-line); margin: 0 6px; } +.composer .tools .send { margin-left: auto; width: 34px; height: 34px; border-radius: 999px; background: var(--mark); color: var(--mark-fg); display: grid; place-items: center; transition: transform 120ms ease, opacity 120ms ease; } +.composer .tools .send .ic { stroke-width: 2.2; } +.composer .tools .send:active { transform: scale(.94); } +.dock .sub { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px 0; font-size: 11px; color: var(--fg-3); } +.dock .sub .typing-line { display: flex; align-items: center; gap: 6px; } +.dock .sub .dot { width: 6px; height: 6px; } +.kbd { font-family: var(--mono); font-size: 10px; padding: 1px 5px; border: 1px solid var(--border); border-radius: 5px; background: var(--card); } + +.menu-btn { display: none; } +@media (max-width: 720px) { + .shell { grid-template-columns: 1fr; } .side { display: none; } + .menu-btn { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; border: 1px solid var(--border); } + .top { flex-wrap: wrap; padding: 10px 14px; row-gap: 8px; } .top .topic, .top .members { display: none; } + .seg { order: 9; width: 100%; overflow-x: auto; scrollbar-width: none; } + .thread-wrap { padding: 16px 14px 8px; } .dock { padding: 10px 12px 12px; } + .composer .tools .tb span { display: none; } +} + +/* ── v2 추가: 결재 상태·비소유자·처리 중 ── */ +.slip .row2 .note { font-size: 11.5px; color: var(--fg-3); } +.slip .busy { display: inline-flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--fg-2); } +.slip .busy .dot { width: 6px; height: 6px; animation: pulse 1.2s ease-in-out infinite; } +.slip.pending.wait .band { background: var(--card-2); color: var(--fg-2); box-shadow: inset 0 -1px 0 var(--mark); } +.slip.pending.wait .band .ic { color: var(--mark-fg); background: var(--mark); border-radius: 999px; padding: 2px; width: 16px; height: 16px; } +.slip.running .band { background: var(--primary); color: var(--primary-fg); } +.slip.running .band .id::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 999px; background: var(--mark); box-shadow: 0 0 0 1.5px var(--primary-fg); margin-right: 8px; animation: pulse 1.2s ease-in-out infinite; vertical-align: middle; } + +/* ── 내 글 상태 ── */ +.mine.sending { opacity: .7; } +.mine.failed .bubble { background: var(--secondary); color: var(--fg); } +.mine .fail { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--danger); } +.mine .fail .ic { width: 13px; height: 13px; } +.sys { font-size: 12.5px; color: var(--fg-3); font-style: italic; display: flex; align-items: center; gap: 8px; } +.sys .ic { width: 13px; height: 13px; } +.skel { display: grid; gap: 8px; max-width: 640px; padding-top: 4px; } +.skel i { display: block; height: 10px; border-radius: 999px; background: var(--secondary); } +.skel i:nth-child(2) { width: 72%; } .skel i:nth-child(3) { width: 40%; } +.row.ghost .av { background: transparent; border-color: transparent; box-shadow: none; } + +/* ── 빈 상태: 척추 위에 3단계 노드 ── */ +.side .hint { padding: 6px 8px; font-size: 12px; color: var(--dark-fg-3); line-height: 1.5; } +.side .hint b { color: var(--dark-fg-2); font-weight: 500; } +.empty { margin: auto; align-self: center; align-content: start; width: 100%; max-width: 560px; padding: 40px 0; display: grid; gap: 22px; } +.empty h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin: 0; line-height: 1.3; } +.empty p { margin: 0; color: var(--fg-2); font-size: 13.5px; line-height: 1.6; } +.steps { position: relative; display: grid; gap: 14px; padding-left: 0; } +.steps::before { content: ''; position: absolute; left: 13px; top: 14px; bottom: 14px; width: 2px; background: var(--border); border-radius: 2px; } +.step { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 14px; align-items: start; position: relative; } +.step .num { width: 28px; height: 28px; border-radius: 999px; display: grid; place-items: center; font-family: var(--mono); font-size: 11.5px; font-weight: 600; background: var(--card); border: 1px solid var(--border); box-shadow: 0 0 0 4px var(--bg); } +.step .num.mark { background: var(--mark); border-color: var(--mark); color: var(--mark-fg); } +.step .card { background: var(--card); border: 1px solid var(--border); border-radius: 6px 20px 20px 20px; padding: 13px 16px; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; } +.step .card b { display: block; font-weight: 600; font-size: 13.5px; } +.step .card span { color: var(--fg-3); font-size: 12px; } +.step .card .kbd { margin: 0 2px; } + +/* ── 로그인: 머리띠 카드 ── */ +.auth { min-height: 100vh; display: grid; place-items: center; padding: 24px; } +.auth .card { width: min(420px, 100%); background: var(--card); border: 1px solid var(--border); border-radius: 22px; overflow: hidden; display: grid; } +.auth .band { display: flex; align-items: center; gap: 10px; padding: 14px 20px; background: var(--primary); color: var(--primary-fg); font-family: var(--mono); font-weight: 600; letter-spacing: 0.16em; font-size: 12px; } +.auth .band svg.logo { width: 14px; height: 14px; fill: var(--mark); } +.auth .band .tag { margin-left: auto; font-family: var(--font); letter-spacing: 0.02em; font-weight: 500; font-size: 11px; opacity: .75; } +.auth .body { padding: 22px 22px 20px; display: grid; gap: 12px; } +.auth h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; margin: 0; line-height: 1.3; } +.auth p { margin: 0; color: var(--fg-2); font-size: 13px; line-height: 1.55; } +.field { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 16px; border-radius: 999px; background: var(--card-2); border: 1px solid var(--border); transition: box-shadow 150ms ease; } +.field .ic { color: var(--fg-3); } +.field:focus-within { box-shadow: 0 0 0 2px var(--ring); } +.field input { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; font-size: 13.5px; } +.field input::placeholder { color: var(--fg-3); } +.auth .btn-primary { height: 44px; font-size: 13px; border-radius: 999px; } +.auth .foot { display: flex; align-items: center; gap: 8px; margin-top: 2px; font-size: 12px; color: var(--fg-3); } +.auth .foot .ic { width: 13px; height: 13px; } +@media (max-width: 720px) { .empty { margin: 0 auto; align-self: flex-start; padding: 8px 0 24px; } .step .card { grid-template-columns: 1fr; } .composer .tools .tb.on span { display: inline; } } diff --git a/apps/messenger/design/tokens.css b/apps/messenger/design/tokens.css new file mode 100644 index 00000000..7631cdc1 --- /dev/null +++ b/apps/messenger/design/tokens.css @@ -0,0 +1,72 @@ +/* linen — 아르고 메신저 테마 가족 시안 토큰(2026-09-03). 이름 체계는 Argo globals.css와 동일, 값만 새로. + 무드: 웜 뉴트럴 에디토리얼 — 그레이지 캔버스 · 순백 카드 · 차콜 사이드바(라이트에서도) · 형광 옐로는 --mark 한 점. + 그림자 없음(표면 명도 단계로만 위계). 옐로는 --accent가 아니라 --mark(점·노드·결재 테두리·전송 버튼 전용). */ +@font-face { font-family: 'Pretendard Variable'; font-weight: 45 920; font-style: normal; font-display: swap; src: url('/fonts/PretendardVariable.woff2') format('woff2-variations'); } + +:root, :root[data-theme='linen'], :root[data-theme='linen-light'] { + color-scheme: light; + --bg: #e9e6df; /* 캔버스 — 웜 그레이지(핀) */ + --card: #fbfaf7; /* 카드 — 순백에 웜 틴트(순백 금지) */ + --card-2: #f1efe9; /* hover */ + --secondary: #e0dcd3; /* active · 내 말풍선 */ + --fg: #26241f; + --fg-2: #5a564d; + --fg-3: #6b655e; + --border: rgba(38, 36, 31, 0.14); + --border-soft: rgba(38, 36, 31, 0.07); + --primary: #1f1e1b; /* 액션 = 명도(차콜 반전). 그래파이트 규칙 */ + --primary-strong: #000000; + --primary-fg: #f4f2ec; + --primary-fg-dim: rgba(244, 242, 236, 0.72); + --primary-fg-line: rgba(244, 242, 236, 0.28); + --primary-soft: rgba(31, 30, 27, 0.07); + --accent: #1f1e1b; --accent-rgb: 31, 30, 27; /* 계기판·스피너는 차콜(옐로 오염 방지) */ + --mark: #e8e400; --mark-rgb: 232, 228, 0; --mark-fg: #1f1e1b; /* 형광 옐로 — 점·노드·결재 테두리·전송 버튼만 */ + --ok: #3f7a52; --ok-soft: rgba(63, 122, 82, 0.12); + --warn: #9a6f1e; --warn-soft: rgba(154, 111, 30, 0.12); + --danger: #a8433a; --danger-soft: rgba(176, 71, 58, 0.1); + --ring: rgba(31, 30, 27, 0.32); + --selection: rgba(232, 228, 0, 0.35); + --shadow-card: none; + --shadow-float: 0 8px 24px rgba(31, 30, 27, 0.14); /* 플로팅 컴포저·팝업 1단만 */ + --mark-wash: color-mix(in srgb, var(--mark) 7%, var(--card)); /* 결재 대기 카드 배경 — 라이트만(다크는 옐로 링 13.5로 충분, 워시는 카키로 탁해짐) */ + --r-sm: 8px; --r: 16px; --r-lg: 18px; --r-pill: 999px; + --font: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', sans-serif; + --mono: ui-monospace, 'SF Mono', Menlo, monospace; + /* 차콜 표면(사이드바·컴포저) — 라이트에서도 어둡다(핀의 프레임 문법) */ + --dark-bg: #1f1e1b; --dark-bg-2: #2a2926; --dark-fg: #e6e2d8; --dark-fg-2: #a8a297; --dark-fg-3: #8d877c; + --dark-line: rgba(230, 226, 216, 0.1); --dark-hover: rgba(255, 255, 255, 0.06); --dark-active: rgba(255, 255, 255, 0.11); + --ink-rgb: 38, 36, 31; --paper-rgb: 251, 250, 247; +} +@media (prefers-color-scheme: dark) { :root[data-theme='linen'] { color-scheme: dark; } } +:root[data-theme='linen-dark'], :root[data-theme='linen'].dark-emul { + color-scheme: dark; + --bg: #1f1e1b; + --card: #2a2926; + --card-2: #33322e; + --secondary: #3c3a35; + --fg: #e6e2d8; + --fg-2: #b3ada2; + --fg-3: #979185; + --border: rgba(230, 226, 216, 0.12); + --border-soft: rgba(230, 226, 216, 0.06); + --primary: #ececea; + --primary-strong: #ffffff; + --primary-fg: #1f1e1b; + --primary-fg-dim: rgba(31, 30, 27, 0.72); + --primary-fg-line: rgba(31, 30, 27, 0.35); + --primary-soft: rgba(236, 236, 234, 0.09); + --accent: #ececea; --accent-rgb: 236, 236, 234; + --mark: #f1ee3a; --mark-rgb: 241, 238, 58; --mark-fg: #1f1e1b; + --ok: #6fb387; --ok-soft: rgba(111, 179, 135, 0.14); + --warn: #d2a24f; --warn-soft: rgba(210, 162, 79, 0.14); + --danger: #e2705f; --danger-soft: rgba(226, 112, 95, 0.13); + --ring: rgba(236, 236, 234, 0.35); + --selection: rgba(241, 238, 58, 0.28); + --shadow-card: none; + --shadow-float: none; /* 다크는 그림자 없음(그록봇·그래파이트 규칙) — 경계선으로 */ + --mark-wash: var(--card); + --dark-bg: #171614; --dark-bg-2: #232220; --dark-fg: #e6e2d8; --dark-fg-2: #a8a297; --dark-fg-3: #8d877c; + --dark-line: rgba(230, 226, 216, 0.1); --dark-hover: rgba(255, 255, 255, 0.06); --dark-active: rgba(255, 255, 255, 0.11); + --ink-rgb: 230, 226, 216; --paper-rgb: 42, 41, 38; +} diff --git a/apps/messenger/index.html b/apps/messenger/index.html new file mode 100644 index 00000000..a4957440 --- /dev/null +++ b/apps/messenger/index.html @@ -0,0 +1,15 @@ + + + + + + Argo Messenger + + + + + +
+ + + diff --git a/apps/messenger/package.json b/apps/messenger/package.json new file mode 100644 index 00000000..f048bcc8 --- /dev/null +++ b/apps/messenger/package.json @@ -0,0 +1,27 @@ +{ + "name": "argo-messenger", + "version": "0.1.0", + "private": true, + "description": "Argo 팀 메신저 — 사람과 크루가 같은 채널에서 일하는 조직 공간(설계: 루트 MESSENGER-DESIGN.md)", + "type": "module", + "scripts": { + "dev": "vite --port 5183 --strictPort", + "build": "vite build", + "preview": "vite preview --port 5183", + "tauri": "tauri", + "test": "node --test test/*.test.mjs" + }, + "dependencies": { + "@supabase/supabase-js": "^2.110.2", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-opener": "^2.5.4", + "marked": "^17.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.1", + "@vitejs/plugin-react": "^5.0.0", + "vite": "^7.1.0" + } +} diff --git a/apps/messenger/public/fonts b/apps/messenger/public/fonts new file mode 120000 index 00000000..0af7f77b --- /dev/null +++ b/apps/messenger/public/fonts @@ -0,0 +1 @@ +../../../public/fonts \ No newline at end of file diff --git a/apps/messenger/public/icon.svg b/apps/messenger/public/icon.svg new file mode 100644 index 00000000..6f0a3193 --- /dev/null +++ b/apps/messenger/public/icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/messenger/src-tauri/Cargo.toml b/apps/messenger/src-tauri/Cargo.toml new file mode 100644 index 00000000..dc4b0899 --- /dev/null +++ b/apps/messenger/src-tauri/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "argo-messenger" +version = "0.1.0" +description = "Argo Messenger — 사람과 크루가 같은 채널에서 일하는 조직 공간" +edition = "2021" +rust-version = "1.77.2" + +[lib] +name = "argo_messenger_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +serde_json = "1" +serde = { version = "1", features = ["derive"] } +tauri = { version = "2", features = [] } +tauri-plugin-opener = "2" diff --git a/apps/messenger/src-tauri/build.rs b/apps/messenger/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/apps/messenger/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/apps/messenger/src-tauri/capabilities/default.json b/apps/messenger/src-tauri/capabilities/default.json new file mode 100644 index 00000000..d3e7d3ea --- /dev/null +++ b/apps/messenger/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Argo Messenger — 기본 권한(창·오프너만)", + "windows": ["main"], + "permissions": ["core:default", { "identifier": "opener:allow-open-url", "allow": [{ "url": "https://*" }] }] +} diff --git a/apps/messenger/src-tauri/icons/128x128.png b/apps/messenger/src-tauri/icons/128x128.png new file mode 100644 index 00000000..549198af Binary files /dev/null and b/apps/messenger/src-tauri/icons/128x128.png differ diff --git a/apps/messenger/src-tauri/icons/128x128@2x.png b/apps/messenger/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..840baf17 Binary files /dev/null and b/apps/messenger/src-tauri/icons/128x128@2x.png differ diff --git a/apps/messenger/src-tauri/icons/32x32.png b/apps/messenger/src-tauri/icons/32x32.png new file mode 100644 index 00000000..0f6d2ac3 Binary files /dev/null and b/apps/messenger/src-tauri/icons/32x32.png differ diff --git a/apps/messenger/src-tauri/icons/64x64.png b/apps/messenger/src-tauri/icons/64x64.png new file mode 100644 index 00000000..f0e8c515 Binary files /dev/null and b/apps/messenger/src-tauri/icons/64x64.png differ diff --git a/apps/messenger/src-tauri/icons/Square107x107Logo.png b/apps/messenger/src-tauri/icons/Square107x107Logo.png new file mode 100644 index 00000000..044362eb Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square107x107Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square142x142Logo.png b/apps/messenger/src-tauri/icons/Square142x142Logo.png new file mode 100644 index 00000000..c74c9b16 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square142x142Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square150x150Logo.png b/apps/messenger/src-tauri/icons/Square150x150Logo.png new file mode 100644 index 00000000..4dde0b55 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square150x150Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square284x284Logo.png b/apps/messenger/src-tauri/icons/Square284x284Logo.png new file mode 100644 index 00000000..cad8f9f1 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square284x284Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square30x30Logo.png b/apps/messenger/src-tauri/icons/Square30x30Logo.png new file mode 100644 index 00000000..91929389 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square30x30Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square310x310Logo.png b/apps/messenger/src-tauri/icons/Square310x310Logo.png new file mode 100644 index 00000000..b10efc07 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square310x310Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square44x44Logo.png b/apps/messenger/src-tauri/icons/Square44x44Logo.png new file mode 100644 index 00000000..3b24d5dd Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square44x44Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square71x71Logo.png b/apps/messenger/src-tauri/icons/Square71x71Logo.png new file mode 100644 index 00000000..32e2b815 Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square71x71Logo.png differ diff --git a/apps/messenger/src-tauri/icons/Square89x89Logo.png b/apps/messenger/src-tauri/icons/Square89x89Logo.png new file mode 100644 index 00000000..1752ca4a Binary files /dev/null and b/apps/messenger/src-tauri/icons/Square89x89Logo.png differ diff --git a/apps/messenger/src-tauri/icons/StoreLogo.png b/apps/messenger/src-tauri/icons/StoreLogo.png new file mode 100644 index 00000000..afb5c624 Binary files /dev/null and b/apps/messenger/src-tauri/icons/StoreLogo.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/apps/messenger/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..2ffbf24b --- /dev/null +++ b/apps/messenger/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..5180e75f Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..3e20f838 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..ac1fcafc Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..fff35f5d Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..ad0519fb Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..18f3da2c Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..b9ad6f54 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..d779b2f0 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..e00d5703 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..96015d6d Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..21522a40 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..9d20136e Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..71378012 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 00000000..b3f71825 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..df55e350 Binary files /dev/null and b/apps/messenger/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/apps/messenger/src-tauri/icons/android/values/ic_launcher_background.xml b/apps/messenger/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 00000000..ea9c223a --- /dev/null +++ b/apps/messenger/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/apps/messenger/src-tauri/icons/icon.icns b/apps/messenger/src-tauri/icons/icon.icns new file mode 100644 index 00000000..a8a3d813 Binary files /dev/null and b/apps/messenger/src-tauri/icons/icon.icns differ diff --git a/apps/messenger/src-tauri/icons/icon.ico b/apps/messenger/src-tauri/icons/icon.ico new file mode 100644 index 00000000..e88320ae Binary files /dev/null and b/apps/messenger/src-tauri/icons/icon.ico differ diff --git a/apps/messenger/src-tauri/icons/icon.png b/apps/messenger/src-tauri/icons/icon.png new file mode 100644 index 00000000..674525d5 Binary files /dev/null and b/apps/messenger/src-tauri/icons/icon.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@1x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 00000000..0a0221a7 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 00000000..5abc8f3c Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 00000000..5abc8f3c Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@3x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 00000000..ed69ae5b Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@1x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 00000000..6d8a2011 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 00000000..fa33b9de Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 00000000..fa33b9de Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@3x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 00000000..0ff6c257 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@1x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 00000000..5abc8f3c Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 00000000..ffb4c6cc Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 00000000..ffb4c6cc Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@3x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 00000000..b0999066 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-512@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 00000000..19e4724a Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 00000000..b0999066 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@3x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 00000000..eb772f0b Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@1x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 00000000..6f2bbc86 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 00000000..0d7f2166 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/apps/messenger/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/apps/messenger/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 00000000..768f7255 Binary files /dev/null and b/apps/messenger/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/apps/messenger/src-tauri/icons/source-icon.svg b/apps/messenger/src-tauri/icons/source-icon.svg new file mode 100644 index 00000000..0ed5b872 --- /dev/null +++ b/apps/messenger/src-tauri/icons/source-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/messenger/src-tauri/src/lib.rs b/apps/messenger/src-tauri/src/lib.rs new file mode 100644 index 00000000..f3cb33df --- /dev/null +++ b/apps/messenger/src-tauri/src/lib.rs @@ -0,0 +1,8 @@ +// Argo 메신저 셸 — 사이드카 없음(Argo 앱과 달리 로컬 서버를 띄우지 않는다). 프론트(Vite 정적 dist)가 Supabase에 직결. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .run(tauri::generate_context!()) + .expect("error while running Argo Messenger"); +} diff --git a/apps/messenger/src-tauri/src/main.rs b/apps/messenger/src-tauri/src/main.rs new file mode 100644 index 00000000..63c41793 --- /dev/null +++ b/apps/messenger/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + argo_messenger_lib::run(); +} diff --git a/apps/messenger/src-tauri/tauri.conf.json b/apps/messenger/src-tauri/tauri.conf.json new file mode 100644 index 00000000..51321ff7 --- /dev/null +++ b/apps/messenger/src-tauri/tauri.conf.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Argo Messenger", + "version": "0.1.0", + "identifier": "com.beyondworks.argo.messenger", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:5183", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "withGlobalTauri": true, + "windows": [{ "title": "Argo Messenger", "width": 1100, "height": 760, "minWidth": 720, "minHeight": 520 }], + "security": { + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://*.supabase.co wss://*.supabase.co http://127.0.0.1:* ws://127.0.0.1:* http://localhost:* ws://localhost:*; frame-ancestors 'none'; object-src 'none'" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"] + } +} diff --git a/apps/messenger/src/App.jsx b/apps/messenger/src/App.jsx new file mode 100644 index 00000000..26a9bbed --- /dev/null +++ b/apps/messenger/src/App.jsx @@ -0,0 +1,1671 @@ +// Argo 메신저 — 조직·채널·메시지·크루·결재 슬립. 데이터는 Supabase 직결(RLS가 경계), 실시간은 private topic org: 방송. +// 룩 = linen v2(apps/messenger/design): 타임라인 척추 · 사람 원/크루 타일 · 2단 다크 독 · 결재 슬립 · 자체 아이콘(icons.jsx). +// Argo 부품은 .shell/.side(테마 토큰 스코프)·.btn·Markdown·imeGuardWith만 쓰고, 나머지는 styles.css의 .msgr-*. +// 1차 범위(MESSENGER-DESIGN.md P1): 로그인 · 조직/초대 · 공개/비공개 채널 · 메시지 · @멘션 · 첨부 · 결재 · 크루 부재중 · 타이핑. +import { Component, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Graph3D } from './graph3d.jsx'; +import * as Panes from './panes.mjs'; import { GRAPH_TAB, MAX_PANES } from './panes.mjs'; // 창·탭 전이(순수) // 활동 그래프 3D(옵시디언식 구·궤도 회전) — 구성은 @argo/graph2d-core 재사용 +import { supabase, configured, q } from './supabase.js'; +import { t as tm } from './i18n.js'; +import { useLang } from '@argo/i18n'; +import { useTheme, THEMES } from '@argo/theme'; +import { Markdown, imeGuardWith } from '@argo/ui'; +import { Sprite, I, STAR_D } from './icons.jsx'; + +const AWAY_MS = 90_000; +/** 크루 등급(부록 I·K) — 서버 함수 msgr_crew_tier와 같은 규칙: 조직 서비스 계정이 소유하고 상주 노드에서 돌면 회사 크루, 그 외는 개인(파견) 크루. 화면 표시용이며 판정 정본은 서버. */ +export const crewTier = (crew, org) => (org?.service_user_id && crew?.owner_user_id === org.service_user_id && crew?.hosting === 'resident') ? 'company' : 'personal'; +const PAGE = 100; +const ATTACH_MAX = 25 * 1024 * 1024; // 브리지 ATTACH_MAX(src/gateway/msgr.mjs)와 같은 값 — 받는 쪽에서만 거절하면 보낸 사람은 이유를 모른다 +const fmtTs = (iso, lang) => new Date(iso).toLocaleTimeString(lang === 'en' ? 'en-US' : 'ko-KR', { hour: '2-digit', minute: '2-digit' }); +const dayKey = (iso) => new Date(iso).toDateString(); +/** 서버 거절 원문 → 사람 문구(검수 M-5: RLS·check 제약 원문이 그대로 뜨던 자리들의 공통 매핑). 모르는 오류는 원문 유지(정직). */ +const friendlyErr = (msg, t) => /row-level security/.test(msg) ? t('err.denied') : /_check\b|violates check constraint/.test(msg) ? t('err.invalid') : /msgr_seat_limit/.test(msg) ? t('seat.limit') : /msgr_org_locked|read-only/.test(msg) ? t('org.locked.short') : msg; +/** 오늘이면 시각만, 아니면 날짜+시각 — 초대 만료(7일 뒤)·노드 마지막 응답·기록처럼 며칠 전후일 수 있는 시각용(시간만 보이면 "오늘 02:31"로 읽힌다 — I-4 실측) */ +const fmtWhen = (iso, lang) => { const d = new Date(iso); const time = fmtTs(iso, lang); if (d.toDateString() === new Date().toDateString()) return time; + return `${d.toLocaleDateString(lang === 'en' ? 'en-US' : 'ko-KR', { month: 'short', day: 'numeric' })} ${time}`; }; +const fmtDay = (iso, lang) => { const d = new Date(iso); return lang === 'en' + ? [d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), d.toLocaleDateString('en-US', { weekday: 'long' })] + : [d.toLocaleDateString('ko-KR', { month: 'long', day: 'numeric' }), d.toLocaleDateString('ko-KR', { weekday: 'long' })]; }; + +/** 메신저 사전 t — 언어 상태는 Argo LanguageProvider(cmd+/ 전환·localStorage argo-lang)를 그대로 쓴다. */ +function useT() { const { lang, setLang, t: ta } = useLang(); return { lang, setLang, ta, t: (k, vars) => tm(k, lang, vars) }; } + +/** 아바타 — 사람은 원, 크루는 둥근 사각 타일 + 옐로 별(시안 v2 모티프 ②). */ +function Av({ name, crew, size, company = false }) { // company: 회사 크루(조직 배지 — 별 대신 각진 해시), 그 외 크루는 별 배지(부록 I·K 등급 표시) + return {(name || '?').slice(0, 1)}{crew && {company ? : }}; +} +/** 본문 속 @멘션을 굵게·줄바꿈 금지로(평가 1차: @와 이름 사이 줄바꿈). */ +function Body({ text }) { + // 앞이 문자열 시작/공백일 때만 멘션(이메일의 @domain은 제외 — 검수 M4) + const parts = String(text ?? '').split(/((?:^|(?<=\s))@[^\s@]+)/g); + return parts.map((p, i) => p.startsWith('@') ? {p} : p); +} + +export default function App() { + const { t } = useT(); + const [session, setSession] = useState(undefined); + useEffect(() => { + if (!supabase) { setSession(null); return; } + supabase.auth.getSession().then(({ data }) => setSession(data.session ?? null)); + const { data: sub } = supabase.auth.onAuthStateChange((_e, s) => setSession(s)); + return () => sub.subscription.unsubscribe(); + }, []); + let body; + if (!configured) body =

{t('auth.notConfigured')}

; + else if (session === undefined) body =
{t('ui.loading')}
; + else if (!session) body = ; + else body = ; + return <>{body}; +} + +/* ─── 로그인: 머리띠 카드 + 이메일 OTP(운영). 개발 빌드에서는 비밀번호 로그인도(로컬 스택엔 메일 서버가 없다). ─── */ +function Auth() { + const { t, lang, setLang } = useT(); + const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [pw, setPw] = useState(''); + const [sent, setSent] = useState(false); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); + const run = async (fn) => { setBusy(true); setErr(''); try { await fn(); } catch (e) { setErr(e.message); } finally { setBusy(false); } }; + return ( +
e.preventDefault()}> +
ARGO{t('auth.tag')}
+
+

{t('auth.title')}

+

{t('auth.desc')}

+ + {!sent ? ( + + ) : (<> +

{t('auth.sent')}

+ + + )} + {import.meta.env.DEV && (<> + {t('auth.devOnly')} + + + )} + {err &&

{err}

} +
{t('auth.foot')}
+
+
+ ); +} + +/* ─── 셸: 레일(조직·채널 칩·크루 카드·멤버 스택) + 본문 ─── */ +function Shell({ session }) { + const { t } = useT(); + const uid = session.user.id; + const [orgs, setOrgs] = useState(null); const [orgId, setOrgId] = useState(null); + const [channels, setChannels] = useState([]); const [chId, setChId] = useState(null); + const [members, setMembers] = useState([]); const [crews, setCrews] = useState([]); + const [chMembers, setChMembers] = useState([]); // 현재 채널의 msgr_channel_members(비공개·DM) + const [ent, setEnt] = useState(null); const [policy, setPolicy] = useState(null); + const orgLocked = ent?.ls_status === 'past_due' || ent?.ls_status === 'unpaid'; // J-2: 결제 문제 = 읽기 전용(서버 msgr_org_locked가 최종) // msgr_org_entitlements(plan·seats) — 좌석 표시·한도 안내 + const [dmMembers, setDmMembers] = useState({}); // dm 채널 id → 멤버 행(레일 라벨용: 나 아닌 참가자) + const [err, setErr] = useState(''); const [note, setNote] = useState(''); + const [tick, setTick] = useState(0); + const [rail, setRail] = useState(false); // 폰 폭: 메뉴 버튼으로 레일 열기 + const [page, setPage] = useState('chat'); // 'chat' | 'settings' | 'docs' — 언어·테마·계정은 설정 페이지(유건 실검수 2026-09-03), 문서 = 조직 문서(G-1) + const [orgMenu, setOrgMenu] = useState(false); + const [sheet, setSheet] = useState(null); // 크루 시트(크루 id) — 허용 범위·소유자·접속 + const [chSheet, setChSheet] = useState(false); // 채널 시트 — 이름·주제·기억·멤버·보관 + const rt = useRef(null); + const loadOrgs = useCallback(async () => { + const rows = await q(supabase.from('msgr_org_members').select('org_id, role, msgr_orgs(id, name, slug, owner_user_id, service_user_id, node_seen_at, pending_owner_user_id, successor_user_id, auto_join_domain, auto_join_role, deleted_at, node_info)').eq('user_id', uid).is('removed_at', null)); + const list = rows.filter((r) => r.msgr_orgs && !r.msgr_orgs.deleted_at).map((r) => ({ id: r.org_id, role: r.role, ...r.msgr_orgs })); + setOrgs(list); + setOrgId((cur) => cur && list.some((o) => o.id === cur) ? cur : (list[0]?.id ?? null)); + setJoinable(await q(supabase.rpc('msgr_joinable_orgs')).catch(() => [])); // J-3: 내 이메일 도메인으로 들어갈 수 있는 조직(서버가 판정) + setDeletedOrgs(await q(supabase.rpc('msgr_my_deleted_orgs')).catch(() => [])); // J-5: 내가 소유한 삭제 예정 조직(30일 안 복구 가능) + }, [uid]); + const restoreOrg = async (o) => { + try { await q(supabase.rpc('msgr_restore_org', { org: o.id })); setNote(t('org.restore.done', { name: o.name })); await loadOrgs(); setOrgId(o.id); } + catch (e) { setErr(/msgr_restore_expired/.test(e.message) ? t('org.restore.expired') : e.message); } + }; + const joinDomain = async (o) => { + try { await q(supabase.rpc('msgr_join_by_domain', { org: o.id })); setNote(t('org.joined')); await loadOrgs(); setOrgId(o.id); } + catch (e) { setErr(/msgr_seat_limit/.test(e.message) ? t('seat.limit') : e.message); } + }; + useEffect(() => { // 초대 링크 수락(?invite=code) + const code = new URLSearchParams(location.search).get('invite'); + (async () => { + try { + if (code) { await q(supabase.rpc('msgr_accept_invite', { code })); history.replaceState(null, '', location.pathname); setNote(t('org.joined')); } + await loadOrgs(); + } catch (e) { setErr(/msgr_seat_limit/.test(e.message) ? t('seat.limit') : e.message); await loadOrgs().catch(() => {}); } + })(); + }, [loadOrgs]); // eslint-disable-line react-hooks/exhaustive-deps + const loadOrg = useCallback(async (id) => { + if (!id) return; + const [chs, mems, crs, e, pol] = await Promise.all([ + q(supabase.from('msgr_channels').select('id, kind, name, topic, crew_memory, personal_crews, created_by, admin_user_ids').eq('org_id', id).is('archived_at', null).order('created_at')), + q(supabase.from('msgr_org_members').select('user_id, role, display_name, expires_at').eq('org_id', id).is('removed_at', null)), + q(supabase.from('msgr_crews').select('id, owner_user_id, slug, display_name, role_text, hosting, status, allow, allow_users, last_seen_at').eq('org_id', id).eq('status', 'active')), + supabase.from('msgr_org_entitlements').select('plan, seats, ls_status').eq('org_id', id).maybeSingle().then((r) => r.data ?? null), + supabase.from('msgr_org_policies').select('allow_default, allow_locked, crew_memory_default, crew_memory_locked, approval_high_by, approver_user_ids, crew_create, crew_runner, crew_model, guest_seats').eq('org_id', id).maybeSingle().then((r) => r.data ?? null), // H-0 조직 정책(없으면 null = 잠금 없음) + ]); + const orgRow = orgs.find((o) => o.id === id); + crs.sort((a, b) => (crewTier(b, orgRow) === 'company') - (crewTier(a, orgRow) === 'company') || a.display_name.localeCompare(b.display_name, 'ko')); // 순서 고정: 회사 크루 먼저, 이름순(QA: 화면마다 순서가 달랐다) + setChannels(chs); setMembers(mems); setCrews(crs); setEnt(e); setPolicy(pol); + setChId((cur) => cur && chs.some((c) => c.id === cur) ? cur : (chs[0]?.id ?? null)); // 라벨용 보조 조회보다 먼저(검수 2R LOW-1: 보조 조회가 던지면 채널 선택이 안 됐다) + const dmIds = chs.filter((c) => c.kind === 'dm').map((c) => c.id); + if (dmIds.length) { try { const rows = await q(supabase.from('msgr_channel_members').select('channel_id, member_kind, member_id').in('channel_id', dmIds)); const map = {}; for (const r of rows) (map[r.channel_id] ??= []).push(r); setDmMembers(map); } catch { setDmMembers({}); } } else setDmMembers({}); + }, [orgs]); + useEffect(() => { loadOrg(orgId).catch((e) => setErr(e.message)); }, [orgId, loadOrg]); + const loadChMembers = useCallback(async (id) => { if (!id) { setChMembers([]); return; } const rows = await q(supabase.from('msgr_channel_members').select('member_kind, member_id, added_by').eq('channel_id', id)); setChMembers(rows); }, []); + useEffect(() => { loadChMembers(chId).catch(() => setChMembers([])); }, [chId, loadChMembers, tick]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { setChSheet(false); }, [chId]); // 채널을 바꿀 때만 닫는다(검수 HIGH-1: tick 의존이면 15초마다 시트가 닫혔다) + const [event, setEvent] = useState(null); const [typing, setTyping] = useState({}); + useEffect(() => { // Realtime — 조직 topic 하나. 방송은 id·채널만 싣는다(본문은 RLS를 지난 조회로). + if (!orgId) return; + let ch; + (async () => { + await supabase.realtime.setAuth(session.access_token); + ch = supabase.channel(`org:${orgId}`, { config: { private: true } }) + .on('broadcast', { event: 'message' }, ({ payload }) => { setEvent({ kind: 'message', ...payload, at: Date.now() }); notifyMention(payload); }) + .on('broadcast', { event: 'approval' }, ({ payload }) => { setEvent({ kind: 'approval', ...payload, at: Date.now() }); notifyApproval(payload); }) + .on('broadcast', { event: 'typing' }, ({ payload }) => setTyping((m) => ({ ...m, [`${payload.channel_id}:${payload.crew_id}`]: Date.now() }))) + .subscribe((status, e) => { if (import.meta.env.DEV) console.log('[rt]', status, e?.message ?? ''); }); + rt.current = ch; + if (import.meta.env.DEV) window.__argoRt = ch; + })(); + return () => { ch?.unsubscribe(); rt.current = null; }; + }, [orgId, session.access_token]); + useEffect(() => { const iv = setInterval(() => setTick((x) => x + 1), 15_000); return () => clearInterval(iv); }, []); + useEffect(() => { if (!rail && !orgMenu) return; const on = (e) => { if (e.key === 'Escape') { setRail(false); setOrgMenu(false); } }; window.addEventListener('keydown', on); return () => window.removeEventListener('keydown', on); }, [rail, orgMenu]); + useEffect(() => { if (tick % 2 === 0 && orgId) loadOrg(orgId).catch(() => {}); }, [tick]); // eslint-disable-line react-hooks/exhaustive-deps + const org = orgs?.find((o) => o.id === orgId); + const me = members.find((m) => m.user_id === uid); + const isAdmin = org && ['owner', 'admin'].includes(org.role); + // F2-5 로컬 알림 — 앱이 숨겨졌거나 다른 채널을 보고 있을 때만. 본문은 싣지 않는다(방송 payload에도 본문이 없다 — RLS 통과 조회가 정본). + const notifyRef = useRef({ channels, members, chId, uid, isAdmin, page }); + notifyRef.current = { channels, members, chId, uid, isAdmin, page }; + const osNotify = (title, body, tag) => { try { if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; const n = new Notification(title, { body, tag }); n.onclick = () => { window.focus(); n.close(); }; } catch { /* 알림 불가 환경 */ } }; + const shouldNotify = (channelId) => { const r = notifyRef.current; return document.visibilityState === 'hidden' || r.page !== 'chat' || r.chId !== channelId; }; + const notifyMention = (payload) => { + const r = notifyRef.current; + if (!payload || payload.author_user_id === r.uid) return; + const mentioned = Array.isArray(payload.mentions) && payload.mentions.some((m) => m?.kind === 'user' && m.id === r.uid); + if (!mentioned || !shouldNotify(payload.channel_id)) return; + const ch = r.channels.find((c) => c.id === payload.channel_id); const who = r.members.find((m) => m.user_id === payload.author_user_id); + osNotify(t('notify.mention', { name: who?.display_name || '?', channel: ch?.name ?? '' }), '', `m:${payload.id}`); + }; + const notifyApproval = (payload) => { + const r = notifyRef.current; + if (!payload || payload.status !== 'pending' || !r.isAdmin || !shouldNotify(payload.channel_id)) return; // 확정권 정본은 서버 — 관리자에게만 알린다(저위험은 소유자가 카드에서 본다) + const ch = r.channels.find((c) => c.id === payload.channel_id); + osNotify(t('notify.approval', { channel: ch?.name ?? '' }), '', `a:${payload.id}`); + }; + const nameOfUser = (id) => members.find((m) => m.user_id === id)?.display_name || id?.slice(0, 8) || '?'; + const crewOf = (id) => crews.find((c) => c.id === id); + const [newOrg, setNewOrg] = useState(null); // 인라인 폼 상태(문자열) — 네이티브 prompt 금지(QA: 사용성·룩 불일치) + const [joinable, setJoinable] = useState([]); // J-3 도메인 자동 가입 후보 + const [railMenu, setRailMenu] = useState(null); const [railConfirm, setRailConfirm] = useState(null); // 레일 행 '…' 메뉴(채널 설정·나가기·보관, 1:1 나가기) — 유건 지적 2026-09-04 + useEffect(() => { if (!railMenu) return; const off = () => { setRailMenu(null); setRailConfirm(null); }; window.addEventListener('click', off); return () => window.removeEventListener('click', off); }, [railMenu]); + const leaveChannel = async (c) => { + setRailMenu(null); setRailConfirm(null); + const mine = crews.filter((cr) => cr.owner_user_id === uid).map((cr) => cr.id); + if (mine.length) { // 내 크루가 그 채널에 있으면 내가 빠지는 순간 크루가 조용히 죽는다(검수 HIGH-3) — 먼저 크루를 빼게 한다 + const stuck = await q(supabase.from('msgr_channel_members').select('member_id').eq('channel_id', c.id).eq('member_kind', 'crew').in('member_id', mine)).catch(() => null); + if (!stuck) return setErr(t('ch.leave.checkFailed')); // 조회 실패면 통과가 아니라 중단(검수 LOW: fail-open) + if (stuck.length) return setErr(t('ch.leave.blocked')); + } + const res = await supabase.from('msgr_channel_members').delete().eq('channel_id', c.id).eq('member_kind', 'user').eq('member_id', uid).select('member_id'); + if (res.error) return setErr(friendlyErr(res.error.message, t)); + if (!res.data?.length) return setErr(t('err.denied')); + setNote(t(c.kind === 'dm' ? 'dm.leave.done' : 'ch.leave.done', { name: c.kind === 'dm' ? dmName(c) : c.name })); + if (chId === c.id) setChId(null); + await loadOrg(orgId).catch(() => {}); + }; + const archiveChannel = async (c) => { + setRailMenu(null); setRailConfirm(null); + const res = await supabase.from('msgr_channels').update({ archived_at: new Date().toISOString() }).eq('id', c.id).select('id'); + if (res.error) return setErr(friendlyErr(res.error.message, t)); + if (!res.data?.length) return setErr(t('ch.noEdit')); + setNote(t('ch.archive.done', { name: c.name })); + if (chId === c.id) setChId(null); + await loadOrg(orgId).catch(() => {}); + }; + const [deletedOrgs, setDeletedOrgs] = useState([]); // J-5 삭제 예정(복구 가능) 조직 + const [newCh, setNewCh] = useState(null); // { name, kind } + const createOrg = async (name) => { + if (!name?.trim()) return; + const slug = `${name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24) || 'org'}-${Date.now().toString(36).slice(-4)}`; + try { const o = await q(supabase.from('msgr_orgs').insert({ name: name.trim(), slug, owner_user_id: uid }).select('id').single()); setNewOrg(null); setOrgMenu(false); await loadOrgs(); setOrgId(o.id); } catch (e) { setErr(e.message); } + }; + const invite = async () => { + try { + const row = await q(supabase.from('msgr_invites').insert({ org_id: orgId, role: 'member', created_by: uid }).select('code').single()); + const link = `${location.origin}${location.pathname}?invite=${row.code}`; + await navigator.clipboard?.writeText(link).catch(() => {}); + setNote(`${t('org.inviteMade')} ${link}`); + } catch (e) { setErr(e.message); } + }; + // 1:1 대화 — 사람(user) 또는 크루(crew)와. 이미 있으면 열고, 없으면 dm 채널 + 멤버(나·상대·크루면 소유자까지) 생성 + const openDm = async (kind, id) => { + try { + const mine = await q(supabase.from('msgr_channel_members').select('channel_id, msgr_channels!inner(id, kind, org_id, archived_at)').eq('member_kind', 'user').eq('member_id', uid)); + const dmIds = mine.filter((r) => r.msgr_channels?.kind === 'dm' && r.msgr_channels.org_id === orgId && !r.msgr_channels.archived_at).map((r) => r.channel_id); + if (dmIds.length) { + const others = await q(supabase.from('msgr_channel_members').select('channel_id, member_kind, member_id').in('channel_id', dmIds)); + const wantUsers = new Set(kind === 'crew' ? [uid, crewOf(id)?.owner_user_id].filter(Boolean) : [uid, id]); // 자기 크루면 {나}, 남의 크루면 {나, 소유자} + const hit = dmIds.find((cid) => { const ms = others.filter((m) => m.channel_id === cid); const users = new Set(ms.filter((m) => m.member_kind === 'user').map((m) => m.member_id)); const crewsIn = ms.filter((m) => m.member_kind === 'crew').map((m) => m.member_id); const sameUsers = users.size === wantUsers.size && [...wantUsers].every((u) => users.has(u)); return sameUsers && (kind === 'crew' ? crewsIn.length === 1 && crewsIn[0] === id : crewsIn.length === 0); }); + if (hit) { setChId(hit); setPage('chat'); setRail(false); setSheet(null); return; } + } + const other = kind === 'crew' ? crewOf(id) : members.find((m) => m.user_id === id); + const name = kind === 'crew' ? other?.display_name : (other?.display_name || id.slice(0, 8)); + const others = [{ kind, id }]; + if (kind === 'crew' && other && other.owner_user_id !== uid) others.push({ kind: 'user', id: other.owner_user_id }); // 크루 = 소유자 동반 규칙 + const cid = await q(supabase.rpc('msgr_create_channel', { org: orgId, kind: 'dm', name: `dm:${name}`, others })); // 나는 서버가 첫 멤버로 넣는다 + await loadOrg(orgId); setChId(cid); setPage('chat'); setRail(false); setSheet(null); + } catch (e) { setErr(e.message); } + }; + const createChannel = async ({ name, kind } = newCh ?? {}) => { + if (!name?.trim()) return; + const priv = kind === 'private'; + try { + if (channels.some((c) => c.kind !== 'dm' && c.name.toLowerCase() === name.trim().toLowerCase())) return setErr(t('ch.dup')); // 검수 M-2: 이름이 기억 페이지 밖에서도 표시 키라 동명은 막는다 + const id = await q(supabase.rpc('msgr_create_channel', { org: orgId, kind: priv ? 'private' : 'public', name: name.trim() })); // 생성+첫 멤버를 서버가 한 번에(생성 직후 열람 예외 폐지 — 검수 HIGH) + setNewCh(null); await loadOrg(orgId); setChId(id); setPage('chat'); + } catch (e) { setErr(/msgr_channel_limit/.test(e.message) ? t('ch.freeLimit') : friendlyErr(e.message, t)); } + }; + const openNewCh = () => { setNewCh({ name: '', kind: 'public' }); setRail(true); }; + if (orgs === null) return
{t('ui.loading')}
; + const channel = channels.find((c) => c.id === chId); + // 채널 중심 구조(유건 지시 2026-09-04): 레일은 채널·1:1만, 크루·멤버는 "이 채널의 구성"으로 본다. 공개 채널 = 조직 멤버 전원 + 이 채널에서 일할 수 있는 크루(채널 정책), 비공개·DM = 채널 멤버. + const usableCrews = channel?.personal_crews && channel.personal_crews !== 'allowed' ? crews.filter((c) => crewTier(c, org) === 'company') : crews; + const chPeople = !channel ? [] : channel.kind === 'public' ? members : members.filter((m) => chMembers.some((x) => x.member_kind === 'user' && x.member_id === m.user_id)); + const chCrews = !channel ? [] : channel.kind === 'public' ? usableCrews : crews.filter((c) => chMembers.some((x) => x.member_kind === 'crew' && x.member_id === c.id)); + // 채널 칩 — 정렬: 현재 → 이름순. 6개 초과는 '+N'(펼치기) + // DM 라벨 = 나 아닌 참가자(검수 MEDIUM-2: 저장된 이름은 생성자 시점). 크루 DM에 다른 사람도 있으면(소유자 동반) '서윤 · 민수'처럼 병기 + const dmName = (c) => { const ms = dmMembers[c.id] ?? []; const crew = ms.find((m) => m.member_kind === 'crew'); const other = ms.find((m) => m.member_kind === 'user' && m.member_id !== uid); if (crew) return [crewOf(crew.member_id)?.display_name ?? c.name.replace(/^dm:/, ''), other ? nameOfUser(other.member_id) : null].filter(Boolean).join(' · '); return other ? nameOfUser(other.member_id) : c.name.replace(/^dm:/, ''); }; + const dms = channels.filter((c) => c.kind === 'dm'); + const sortedCh = [...channels].filter((c) => c.kind !== 'dm').sort((a, b) => (a.kind === 'private') - (b.kind === 'private') || a.name.localeCompare(b.name)); // 공개 먼저·이름순 고정(선택한 채널을 위로 끌어올리면 목록이 뛴다) + return ( +
+ {rail &&
setRail(false)} role="presentation" />} + +
+ {sheet && crewOf(sheet) && setSheet(null)} onChanged={() => loadOrg(orgId).catch(() => {})} onPosted={() => setEvent({ kind: 'message', channel_id: chId, at: Date.now() })} onNote={setNote} onError={setErr} onDm={() => openDm('crew', sheet)} />} + {chSheet && channel && { setChSheet(false); setSheet(id); }} onDm={(id) => openDm('user', id)} nameOfUser={nameOfUser} onClose={() => setChSheet(false)} onChanged={async () => { await loadOrg(orgId).catch(() => {}); await loadChMembers(chId).catch(() => {}); }} onArchived={() => { setChSheet(false); setChId(null); loadOrg(orgId).catch(() => {}); }} onNote={setNote} onError={setErr} />} + {orgLocked &&
{t(isAdmin ? 'org.locked.admin' : 'org.locked')}
} + {(err || note) && ( +
+ {err ? `${t('ui.error')}: ${err}` : note} + +
+ )} + setPage('chat')}> + {page === 'activity' && org ? ( + setPage('chat')} onMenu={() => setRail(true)} onOpenChannel={(id) => { setChId(id); setPage('chat'); }} /> + ) : page === 'settings' ? ( + loadOrg(orgId).catch((e) => setErr(e.message))} onOrgsChanged={() => loadOrgs().catch((e) => setErr(e.message))} onNote={setNote} onError={setErr} onBack={() => setPage('chat')} onMenu={() => setRail(true)} /> + ) : chId ? ( + setRail(true)} onCrew={setSheet} onTitle={() => setChSheet(true)} dmName={dmName} /> + ) : ( + setRail(true)} createOrg={() => { setOrgMenu(true); setNewOrg(''); }} createChannel={openNewCh} invite={isAdmin ? invite : null} joinable={joinable} joinDomain={joinDomain} deletedOrgs={deletedOrgs} restoreOrg={restoreOrg} /> + )} + +
+
+ ); +} + +/* ─── 크루 시트: 소유자·실행 위치·접속 + 누가 시킬 수 있나(소유자만 편집, RLS msgr_crews_update_owner) + 허용 요청 ─── */ +function CrewSheet({ crew, org, uid, me, members, policy, channelId, nameOfUser, onClose, onChanged, onPosted, onNote, onError, onDm }) { + const { t, lang } = useT(); + const owner = crew.owner_user_id === uid; + const tier = crewTier(crew, org); // H-3: 회사 크루 / 개인(파견) 크루 — 판정 정본은 서버 msgr_crew_tier + const locked = !!policy?.allow_locked; // H-0: 조직 정책이 잠그면 소유자도 못 바꾼다(서버 트리거 msgr_crew_policy_gate가 최종) + const [allow, setAllow] = useState(crew.allow); const [list, setList] = useState(crew.allow_users ?? []); const [busy, setBusy] = useState(false); + useEffect(() => { setAllow(crew.allow); setList(crew.allow_users ?? []); }, [crew.id, crew.allow, crew.allow_users]); + useEffect(() => { const on = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', on); return () => window.removeEventListener('keydown', on); }, [onClose]); + const on = crew.last_seen_at && Date.now() - Date.parse(crew.last_seen_at) < AWAY_MS; + const canMe = owner || crew.allow === 'all' || (crew.allow === 'list' && (crew.allow_users ?? []).includes(uid)); + const save = async (nextAllow, nextList) => { + setBusy(true); + const res = await supabase.from('msgr_crews').update({ allow: nextAllow, allow_users: nextAllow === 'list' ? nextList : [] }).eq('id', crew.id).select('id'); + setBusy(false); + if (res.error) return onError(/msgr_policy_locked/.test(res.error.message) ? t('err.policyLocked') : res.error.message); + if (!res.data?.length) return onError(t('crew.allow.readonly', { name: nameOfUser(crew.owner_user_id) })); // RLS 0행 + onNote(t('crew.allow.saved')); onChanged(); + }; + const pickAllow = (v) => { setAllow(v); if (v !== 'list') save(v, []); }; + const toggle = (id) => { const next = list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; setList(next); save('list', next); }; + const request = async () => { + if (!channelId) return; + const ownerName = nameOfUser(crew.owner_user_id); const meName = me?.display_name || uid.slice(0, 8); + const body = t('crew.request.body', { owner: ownerName, me: meName, crew: crew.display_name }); + const { error } = await supabase.from('msgr_messages').insert({ channel_id: channelId, author_kind: 'user', author_user_id: uid, body, mentions: [{ kind: 'user', id: crew.owner_user_id }], client_msg_id: crypto.randomUUID() }); + if (error) return onError(error.message); + onNote(t('crew.request.sent')); onPosted?.(); onClose(); + }; + const others = members.filter((m) => m.user_id !== crew.owner_user_id); + return ( +
+
+ +
+ ); +} + +/* ─── 채널 시트: 이름·주제(관리자·생성자) · 크루 기억 스위치 · 멤버(비공개·DM: 사람·크루 추가/내보내기, 크루=소유자 동반) · 보관 ─── */ +function ChannelSheet({ channel, org, uid, isAdmin, policy, members, crews, chMembers, people = [], chCrews = [], ent, onInvite, onCrew, onDm, nameOfUser, onClose, onChanged, onArchived, onNote, onError }) { + const { t } = useT(); + const chAdmins = channel.admin_user_ids ?? []; + const canEdit = isAdmin || channel.created_by === uid || chAdmins.includes(uid); // J-1: 채널 관리자도 설정·멤버 관리(최종은 RLS msgr_can_manage_channel) + const canAssignAdmins = (isAdmin || channel.created_by === uid) && channel.kind !== 'dm'; // 지정은 조직 관리자·생성자만(자기 증식 방지 — 서버 트리거와 동일) + const toggleChAdmin = async (userId) => { const next = chAdmins.includes(userId) ? chAdmins.filter((x) => x !== userId) : [...chAdmins, userId]; await upd({ admin_user_ids: next }, t('ch.admin.saved')); }; + const memLocked = !!policy?.crew_memory_locked; // H-0: 서버 트리거 msgr_channel_policy_gate가 최종 + const [name, setName] = useState(channel.name); const [topic, setTopic] = useState(channel.topic ?? ''); const [busy, setBusy] = useState(false); const [add, setAdd] = useState(null); // null | 'menu' | 'user' | 'crew' | 'guest' | 'newcrew' — '+ 추가' 하나로 모은다(UX 재구성) + const [rowMenu, setRowMenu] = useState(null); const [more, setMore] = useState(false); // 행 '…' 메뉴 · '채널 설정' 접힘 + useEffect(() => { setName(channel.name); setTopic(channel.topic ?? ''); }, [channel.id]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { const on = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', on); return () => window.removeEventListener('keydown', on); }, [onClose]); + const upd = async (patch, okMsg) => { + setBusy(true); + const res = await supabase.from('msgr_channels').update(patch).eq('id', channel.id).select('id'); + setBusy(false); + if (res.error) return onError(/msgr_policy_locked/.test(res.error.message) ? t('err.policyLocked') : res.error.message); + if (!res.data?.length) return onError(t('ch.noEdit')); + if (okMsg) onNote(okMsg); await onChanged(); + }; + const saveText = () => upd({ name: name.trim() || channel.name, topic: topic.trim() || null }, t('ch.saved')); + const addMember = async (kind, id) => { + setBusy(true); + const rows = [{ channel_id: channel.id, member_kind: kind, member_id: id, added_by: uid }]; + const crew = kind === 'crew' ? crews.find((c) => c.id === id) : null; + if (crew && !chMembers.some((m) => m.member_kind === 'user' && m.member_id === crew.owner_user_id)) rows.push({ channel_id: channel.id, member_kind: 'user', member_id: crew.owner_user_id, added_by: uid }); // 크루 = 소유자 동반 + const res = await supabase.from('msgr_channel_members').upsert(rows, { onConflict: 'channel_id,member_kind,member_id' }); + setBusy(false); setAdd(null); + if (res.error) return onError(/msgr_channel_personal_blocked/.test(res.error.message) ? t('err.channelPersonalBlocked') : res.error.message); // I-3: 서버 게이트의 거절을 정직한 문구로 + await onChanged(); + }; + const removeMember = async (kind, id) => { + if (kind === 'user' && crews.some((c) => c.owner_user_id === id && chMembers.some((m) => m.member_kind === 'crew' && m.member_id === c.id))) return onError(t('ch.remove.ownerBlocked')); // 검수 HIGH-3: 소유자가 빠지면 크루가 조용히 죽는다 + setBusy(true); + const res = await supabase.from('msgr_channel_members').delete().eq('channel_id', channel.id).eq('member_kind', kind).eq('member_id', id).select('member_id'); + setBusy(false); + if (res.error) return onError(res.error.message); + if (!res.data?.length) return onError(t('ch.noEdit')); + await onChanged(); + }; + const [confirmArchive, setConfirmArchive] = useState(false); // 네이티브 confirm 대신 2단계 버튼(QA) + const archive = async () => { await upd({ archived_at: new Date().toISOString() }); onArchived(); }; + const userIds = new Set(chMembers.filter((m) => m.member_kind === 'user').map((m) => m.member_id)); + const crewIds = new Set(chMembers.filter((m) => m.member_kind === 'crew').map((m) => m.member_id)); + const addableUsers = members.filter((m) => !userIds.has(m.user_id) && m.user_id !== org?.service_user_id); // 회사 크루 서버(기계 계정)는 사람 후보가 아니다(실측: 첫 칩이 '회사 노드') + const addableCrews = crews.filter((c) => !crewIds.has(c.id) && ((channel.personal_crews ?? 'allowed') !== 'blocked' || crewTier(c, org) === 'company')); // I-3: 차단 채널엔 회사 크루만 후보(안 될 버튼 노출 금지 — 최종은 서버 게이트) + const scoped = channel.kind !== 'public'; + const nodeSet = !!org?.service_user_id; const nodeOn = nodeSet && !!org?.node_seen_at && Date.now() - Date.parse(org.node_seen_at) < AWAY_MS; // I-5·검수 M-4: 노드가 살아 있어야 만들 수 있다(죽은 노드면 영원한 '만드는 중') + const crewCreate = policy?.crew_create ?? 'channel_admin'; + const myRole = members.find((m) => m.user_id === uid)?.role ?? 'member'; + const canCreateCrew = channel.kind !== 'dm' && nodeOn && myRole !== 'guest' && (isAdmin || crewCreate === 'member' || (crewCreate === 'channel_admin' && canEdit)); // 권한 행렬 — 최종은 RLS msgr_can_create_crew // 권한 행렬 — 최종은 RLS msgr_can_create_crew + const [newCrew, setNewCrew] = useState(null); const [requests, setRequests] = useState([]); const doneSeen = useRef(null); + const [guestDays, setGuestDays] = useState(30); // J-4: 비공개 채널 게스트 링크(채널 관리자도 발급 — 최종은 RLS) + const guestInvite = async () => { + setBusy(true); + const res = await supabase.from('msgr_invites').insert({ org_id: org.id, role: 'guest', channel_id: channel.id, guest_days: guestDays, created_by: uid }).select('code').single(); + setBusy(false); + if (res.error) return onError(friendlyErr(res.error.message, t)); + const link = `${location.origin}${location.pathname}?invite=${res.data.code}`; + await navigator.clipboard?.writeText(link).catch(() => {}); + onNote(`${t('ch.guest.made', { days: guestDays })} ${link}`); + }; + const loadRequests = useCallback(async () => { + const rows = await q(supabase.from('msgr_crew_requests').select('id, name, status, error, crew_id, created_at, done_at').eq('org_id', org.id).eq('channel_id', channel.id).order('created_at', { ascending: false }).limit(10)); + setRequests(rows); + const done = rows.filter((r) => r.status === 'done').map((r) => r.id).join(','); + if (doneSeen.current !== null && doneSeen.current !== done) onChanged(); // 노드가 만들었다 → 참여 구성 다시 읽기 + doneSeen.current = done; + }, [org?.id, channel.id]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { if (!nodeOn || channel.kind === 'dm') return; loadRequests().catch(() => {}); const iv = setInterval(() => loadRequests().catch(() => {}), 3000); return () => clearInterval(iv); }, [loadRequests, nodeOn, channel.kind]); + const submitCrew = async () => { + setBusy(true); + const res = await supabase.from('msgr_crew_requests').insert({ org_id: org.id, channel_id: newCrew.orgWide ? null : channel.id, name: newCrew.name.trim(), role_text: newCrew.role.trim(), prompt: newCrew.prompt.trim(), created_by: uid }).select('id'); + setBusy(false); + if (res.error) return onError(friendlyErr(res.error.message, t)); + setNewCrew(null); setAdd(null); onNote(t('ch.crew.new.sent')); loadRequests().catch(() => {}); + }; + const canAddPeople = scoped && canEdit && channel.kind !== 'dm' && addableUsers.length > 0; + const canAddCrew = scoped && canEdit && channel.kind !== 'dm' && addableCrews.length > 0; + const canGuest = channel.kind === 'private' && canEdit; + const showNewCrewItem = channel.kind !== 'dm' && (canCreateCrew || (isAdmin && !nodeOn)); // 관리자에겐 서버가 없거나 죽어도 항목은 보이되 비활성+이유(안 될 버튼 노출 금지의 예외: 왜 안 되는지 알려줘야 하는 자리) + const canAddAny = canAddPeople || canAddCrew || canGuest || showNewCrewItem; + const personalLabel = (v) => t(`ch.personal.${v}`); + const pendingReqs = requests.filter((r) => r.status !== 'done' || Date.now() - Date.parse(r.done_at ?? r.created_at) < 120_000); + return ( +
+
+