Phase 4: 인박스(Smart Inbox) 페이지
- 멀티모달 캡처 컴포저(텍스트 Enter/보내기 + mic·image 스텁)
- 실시간 AI 분류: POST capture(동기) → 생각중 펄스 → 결과 칩(type/sphere/proj/due/when/extra)
+ 이유 한 줄 + heuristic 폴백 배지("규칙 기반(오프라인)")
- 확인(confirm)=실체화(federation): task → 실제 작업 생성 + 작업 페이지 등장
- 다르게 분류(reclassify) 타입 순환 task→event→idea, 낙관적 미리보기
- 분류 원칙 카드 4건(원문 그대로) + "오늘 어디로 갔나" 행선지 카드(동적 집계)
- sinbox.css 픽셀 이식 + dash-base.css(공유 레이아웃/카드, 대시보드 재사용)
- 서버 컴포넌트 초기 fetch + 클라 낙관적 업데이트/롤백, 전체/업무/개인 필터
검증: 백엔드 pytest 46, 프론트 vitest 42, playwright 28(shell+tasks+inbox+a11y),
tsc/eslint clean, build OK. 인박스→작업 연합 e2e 통과, 4 골든 + 폴백 배지 + voice 스텁 검증.
라이브 시각 확인(컴포저/칩/이유/원칙/행선지).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
parent
d2d7c17ad3
commit
cb028df08a
@ -1,4 +1,18 @@
|
||||
// frontend/app/inbox/page.tsx (Phase 1 스텁 — Phase 4에서 교체)
|
||||
export default function Page() {
|
||||
return <h1 className="ph-title">인박스</h1>;
|
||||
// frontend/app/inbox/page.tsx
|
||||
import "@/styles/inbox.css";
|
||||
import InboxView from "@/components/inbox/InboxView";
|
||||
import { getInbox } from "@/lib/inbox/api";
|
||||
import type { InboxItem } from "@/lib/types";
|
||||
|
||||
export const dynamic = "force-dynamic"; // 시드 데이터 항상 최신
|
||||
|
||||
export default async function InboxPage() {
|
||||
let items: InboxItem[] = [];
|
||||
let loadError = false;
|
||||
try {
|
||||
items = await getInbox();
|
||||
} catch {
|
||||
loadError = true;
|
||||
}
|
||||
return <InboxView initialItems={items} initialLoadError={loadError} />;
|
||||
}
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
// frontend/components/inbox/CaptureComposer.tsx
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
// MVP 스텁: 음성/이미지는 더미 transcript/caption 주입
|
||||
const VOICE_STUB = "음성 메모 0:09 — 엄마 생신 선물 미리 알아보기";
|
||||
const IMAGE_STUB = "이미지 캡처 — 영수증/스크린샷 (자동 인식 결과)";
|
||||
|
||||
export default function CaptureComposer({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const submitText = () => {
|
||||
const t = input.trim();
|
||||
if (!t) return;
|
||||
onSubmit(t, "text");
|
||||
setInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sb-cmd">
|
||||
<Icon name="spark" />
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submitText()}
|
||||
placeholder="갑자기 생각난 것 아무거나… 예) 다음 주 한국 가는 비행기 티켓 사기"
|
||||
aria-label="인박스에 빠르게 캡처"
|
||||
/>
|
||||
<button
|
||||
className="sb-mode"
|
||||
aria-label="음성으로 캡처(MVP 스텁)"
|
||||
title="음성 입력은 MVP에서 더미 메모로 동작합니다"
|
||||
onClick={() => onSubmit(VOICE_STUB, "voice")}
|
||||
>
|
||||
<Icon name="mic" />
|
||||
</button>
|
||||
<button
|
||||
className="sb-mode"
|
||||
aria-label="이미지로 캡처(MVP 스텁)"
|
||||
title="이미지 입력은 MVP에서 더미 캡션으로 동작합니다"
|
||||
onClick={() => onSubmit(IMAGE_STUB, "image")}
|
||||
>
|
||||
<Icon name="image" />
|
||||
</button>
|
||||
<button className="sb-send" onClick={submitText} aria-label="보내기">
|
||||
<Icon name="arrow" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
// frontend/components/inbox/CaptureRow.tsx
|
||||
"use client";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { IconName } from "@/components/icons/paths";
|
||||
import type { UiInboxItem } from "@/lib/types";
|
||||
import ReasonLine from "./ReasonLine";
|
||||
import RouteChips from "./RouteChips";
|
||||
|
||||
export default function CaptureRow({
|
||||
item: c,
|
||||
onConfirm,
|
||||
onReType,
|
||||
}: {
|
||||
item: UiInboxItem;
|
||||
onConfirm: (id: string) => void;
|
||||
onReType: (id: string) => void;
|
||||
}) {
|
||||
const kindIcon: IconName = c.kind === "voice" ? "mic" : c.kind === "image" ? "image" : "pen";
|
||||
const canAct = c.status === "classified"; // 원본의 new(분류 완료, 액션 가능)
|
||||
|
||||
return (
|
||||
<div className={"sb-cap" + (c.fresh ? " fresh" : "")}>
|
||||
<div className={"cap-k " + c.kind}>
|
||||
<Icon name={kindIcon} />
|
||||
</div>
|
||||
<div className="sb-body">
|
||||
<div className="sb-raw">{c.raw}</div>
|
||||
|
||||
{c.status === "thinking" ? (
|
||||
<div className="sb-think" role="status" aria-live="polite">
|
||||
<span className="tdot" />
|
||||
<span className="tdot" />
|
||||
<span className="tdot" />
|
||||
아리가 분류하고 있어요
|
||||
</div>
|
||||
) : c.status === "error" ? (
|
||||
<div className="sb-err" role="alert">
|
||||
분류에 실패했어요.{" "}
|
||||
<button className="sb-retry" onClick={() => onReType(c.id)}>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
) : c.classification ? (
|
||||
<>
|
||||
<RouteChips r={c.classification} editable={canAct} onReType={() => onReType(c.id)} />
|
||||
<ReasonLine reason={c.classification.reason} fallbackUsed={c.fallbackUsed} />
|
||||
{canAct && (
|
||||
<div className="sb-acts">
|
||||
<button className="sb-ok" onClick={() => onConfirm(c.id)}>
|
||||
<Icon name="tick" w={3} />
|
||||
좋아요, 그렇게 해줘
|
||||
</button>
|
||||
<button className="sb-alt" onClick={() => onReType(c.id)}>
|
||||
<Icon name="swap" />
|
||||
다르게 분류
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="cap-time">
|
||||
{c.status === "confirmed" ? (
|
||||
<span className="sb-done">
|
||||
<Icon name="tick" w={3} />
|
||||
</span>
|
||||
) : (
|
||||
c.time
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
// frontend/components/inbox/InboxView.tsx — 인박스 클라이언트 루트
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { captureInbox, confirmInbox, reclassifyInbox } from "@/lib/inbox/api";
|
||||
import { nextType } from "@/lib/inbox/presentation";
|
||||
import type { InboxItem, RouteType, Sphere, UiInboxItem } from "@/lib/types";
|
||||
import ClassifyPrinciples from "./ClassifyPrinciples";
|
||||
import SmartInbox from "./SmartInbox";
|
||||
import TodayRouted from "./TodayRouted";
|
||||
|
||||
function relTime(iso: string): string {
|
||||
if (!iso) return "방금";
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(diff)) return "방금";
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "방금";
|
||||
if (m < 60) return `${m}분 전`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}시간 전`;
|
||||
return `${Math.floor(h / 24)}일 전`;
|
||||
}
|
||||
|
||||
function toUi(it: InboxItem): UiInboxItem {
|
||||
return { ...it, status: it.status, time: relTime(it.created_at) };
|
||||
}
|
||||
|
||||
export default function InboxView({
|
||||
initialItems,
|
||||
initialLoadError,
|
||||
}: {
|
||||
initialItems: InboxItem[];
|
||||
initialLoadError: boolean;
|
||||
}) {
|
||||
const [items, setItems] = useState<UiInboxItem[]>(() => initialItems.map(toUi));
|
||||
const [filter, setFilter] = useState<"all" | Sphere>("all");
|
||||
const tmpSeq = useRef(0);
|
||||
|
||||
const clearFresh = (xs: UiInboxItem[]) => xs.map((x) => (x.fresh ? { ...x, fresh: false } : x));
|
||||
|
||||
/* ---- 캡처 제출 ---- */
|
||||
const submit = async (raw: string, kind: "text" | "voice" | "image") => {
|
||||
const text = raw.trim();
|
||||
if (!text) return;
|
||||
const tmpId = "tmp-" + ++tmpSeq.current;
|
||||
setItems((xs) => [
|
||||
{
|
||||
id: tmpId,
|
||||
kind,
|
||||
raw: text,
|
||||
status: "thinking",
|
||||
time: "방금",
|
||||
created_at: new Date(0).toISOString(),
|
||||
materialized_task_id: null,
|
||||
classification: null,
|
||||
fresh: true,
|
||||
} as UiInboxItem,
|
||||
...clearFresh(xs),
|
||||
]);
|
||||
try {
|
||||
const { item, classification } = await captureInbox({ kind, raw: text });
|
||||
setItems((xs) =>
|
||||
xs.map((x) =>
|
||||
x.id === tmpId
|
||||
? {
|
||||
...item,
|
||||
classification,
|
||||
status: "classified",
|
||||
time: "방금",
|
||||
fresh: true,
|
||||
fallbackUsed: classification.model === "heuristic",
|
||||
}
|
||||
: x,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setItems((xs) => xs.map((x) => (x.id === tmpId ? { ...x, status: "error" } : x)));
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- 다르게 분류 (타입 순환) ---- */
|
||||
const reType = async (id: string) => {
|
||||
const cur = items.find((x) => x.id === id);
|
||||
if (!cur?.classification) return;
|
||||
const original = cur.classification.type;
|
||||
const wanted = nextType(original);
|
||||
// 낙관적 미리보기
|
||||
setItems((xs) =>
|
||||
xs.map((x) =>
|
||||
x.id === id && x.classification
|
||||
? { ...x, classification: { ...x.classification, type: wanted } }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const { item, classification } = await reclassifyInbox(id, wanted as RouteType);
|
||||
setItems((xs) =>
|
||||
xs.map((x) =>
|
||||
x.id === id
|
||||
? {
|
||||
...item,
|
||||
classification,
|
||||
status: "classified",
|
||||
time: x.time,
|
||||
fresh: x.fresh,
|
||||
fallbackUsed: classification.model === "heuristic",
|
||||
}
|
||||
: x,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 롤백: 원래 타입 복구
|
||||
setItems((xs) =>
|
||||
xs.map((x) =>
|
||||
x.id === id && x.classification
|
||||
? { ...x, classification: { ...x.classification, type: original } }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- 좋아요(confirm) → 실체화 ---- */
|
||||
const confirm = async (id: string) => {
|
||||
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "confirmed" } : x)));
|
||||
try {
|
||||
const res = await confirmInbox(id);
|
||||
setItems((xs) =>
|
||||
xs.map((x) =>
|
||||
x.id === id
|
||||
? { ...res.item, classification: x.classification, status: "confirmed", time: x.time }
|
||||
: x,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, status: "classified" } : x)));
|
||||
}
|
||||
};
|
||||
|
||||
const todayRouted = items.filter(
|
||||
(x) => x.classification && x.status !== "thinking" && x.status !== "error",
|
||||
).length;
|
||||
const shown = items.filter(
|
||||
(x) => filter === "all" || (x.classification && x.classification.sphere === filter),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pagehead">
|
||||
<div>
|
||||
<div className="ph-eyebrow">
|
||||
<span>오늘 {todayRouted}건 정리</span>
|
||||
<span className="sep" />
|
||||
<span>분류를 고민한 시간 0초</span>
|
||||
</div>
|
||||
<h1 className="ph-title">
|
||||
스마트 인박스 <em>일단 적으세요</em>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="work">
|
||||
<div className="board">
|
||||
{initialLoadError && (
|
||||
<div className="inbox-loaderr card sp2" style={{ gridColumn: "span 4" }}>
|
||||
<Icon name="x" /> 인박스를 불러오지 못했어요. 새로고침하거나 백엔드 연결을 확인하세요.
|
||||
</div>
|
||||
)}
|
||||
<SmartInbox
|
||||
items={shown}
|
||||
todayRouted={todayRouted}
|
||||
filter={filter}
|
||||
onFilter={setFilter}
|
||||
onSubmit={submit}
|
||||
onConfirm={confirm}
|
||||
onReType={reType}
|
||||
/>
|
||||
<ClassifyPrinciples />
|
||||
<TodayRouted items={items} todayRouted={todayRouted} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
// frontend/components/inbox/ReasonLine.tsx
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
export default function ReasonLine({
|
||||
reason,
|
||||
fallbackUsed,
|
||||
}: {
|
||||
reason: string;
|
||||
fallbackUsed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="sb-reason">
|
||||
{fallbackUsed && (
|
||||
<span className="sb-fallback" title="LLM에 연결할 수 없어 규칙 기반으로 분류했어요">
|
||||
<Icon name="zap" />
|
||||
규칙 기반(오프라인)
|
||||
</span>
|
||||
)}
|
||||
{reason}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
// frontend/components/inbox/RouteChips.tsx
|
||||
"use client";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { SPHERE, TYPE_ICON, TYPE_LABEL } from "@/lib/inbox/presentation";
|
||||
import type { Classification } from "@/lib/types";
|
||||
|
||||
export default function RouteChips({
|
||||
r,
|
||||
editable,
|
||||
onReType,
|
||||
}: {
|
||||
r: Classification;
|
||||
editable: boolean;
|
||||
onReType: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sb-route">
|
||||
<span className="r-arrow">
|
||||
<Icon name="arrow" />
|
||||
</span>
|
||||
|
||||
{/* 타입 칩: 클릭 시 다르게 분류(편집 가능할 때만) */}
|
||||
<button
|
||||
className={"r-chip type " + r.type}
|
||||
onClick={() => editable && onReType()}
|
||||
disabled={!editable}
|
||||
aria-label={`타입: ${TYPE_LABEL[r.type]}${editable ? " (눌러서 변경)" : ""}`}
|
||||
>
|
||||
<Icon name={TYPE_ICON[r.type]} />
|
||||
{TYPE_LABEL[r.type]}
|
||||
</button>
|
||||
|
||||
{/* sphere 칩 (고정색) */}
|
||||
<span className="r-chip sphere">
|
||||
<span className="pdot" style={{ background: SPHERE[r.sphere].tone }} />
|
||||
{SPHERE[r.sphere].label}
|
||||
</span>
|
||||
|
||||
{/* proj 칩 (동적 tone) */}
|
||||
<span className="r-chip proj">
|
||||
<span className="pdot" style={{ background: `var(--${r.tone})` }} />
|
||||
{r.proj_label}
|
||||
</span>
|
||||
|
||||
{r.due_text && (
|
||||
<span className="r-chip">
|
||||
<Icon name="cal" />
|
||||
{r.due_text}
|
||||
</span>
|
||||
)}
|
||||
{r.when_text && (
|
||||
<span className="r-chip">
|
||||
<Icon name="clock" />
|
||||
{r.when_text}
|
||||
</span>
|
||||
)}
|
||||
{r.extra && (
|
||||
<span className="r-chip auto">
|
||||
<Icon name="zap" />
|
||||
{r.extra}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
// frontend/components/inbox/SmartInbox.tsx
|
||||
"use client";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import type { Sphere, UiInboxItem } from "@/lib/types";
|
||||
import CaptureComposer from "./CaptureComposer";
|
||||
import CaptureRow from "./CaptureRow";
|
||||
import SphereFilters from "./SphereFilters";
|
||||
|
||||
export default function SmartInbox({
|
||||
items,
|
||||
todayRouted,
|
||||
filter,
|
||||
onFilter,
|
||||
onSubmit,
|
||||
onConfirm,
|
||||
onReType,
|
||||
}: {
|
||||
items: UiInboxItem[];
|
||||
todayRouted: number;
|
||||
filter: "all" | Sphere;
|
||||
onFilter: (f: "all" | Sphere) => void;
|
||||
onSubmit: (raw: string, kind: "text" | "voice" | "image") => void;
|
||||
onConfirm: (id: string) => void;
|
||||
onReType: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="card sbox sp2">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="inbox" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>스마트 인박스</h3>
|
||||
<div className="sub">업무든 일상이든 일단 적기 — 분류는 아리가 해요</div>
|
||||
</div>
|
||||
<span className="count">오늘 {todayRouted}건 정리</span>
|
||||
</div>
|
||||
|
||||
<CaptureComposer onSubmit={onSubmit} />
|
||||
<SphereFilters filter={filter} onFilter={onFilter} />
|
||||
|
||||
<div className="sb-list">
|
||||
{items.length === 0 ? (
|
||||
<div className="sb-empty">
|
||||
아직 비어 있어요. 위에 아무거나 적어보세요 — 분류는 아리가 할게요.
|
||||
</div>
|
||||
) : (
|
||||
items.map((c) => (
|
||||
<CaptureRow key={c.id} item={c} onConfirm={onConfirm} onReType={onReType} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
// frontend/components/inbox/SphereFilters.tsx
|
||||
"use client";
|
||||
import { SPHERE } from "@/lib/inbox/presentation";
|
||||
import type { Sphere } from "@/lib/types";
|
||||
|
||||
const TABS: ["all" | Sphere, string][] = [
|
||||
["all", "전체"],
|
||||
["work", "업무"],
|
||||
["life", "개인"],
|
||||
];
|
||||
|
||||
export default function SphereFilters({
|
||||
filter,
|
||||
onFilter,
|
||||
}: {
|
||||
filter: "all" | Sphere;
|
||||
onFilter: (f: "all" | Sphere) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sb-filters">
|
||||
{TABS.map(([k, l]) => (
|
||||
<button
|
||||
key={k}
|
||||
className={"sb-f" + (filter === k ? " on" : "")}
|
||||
onClick={() => onFilter(k)}
|
||||
aria-pressed={filter === k}
|
||||
>
|
||||
{k !== "all" && (
|
||||
<span className="fdot" style={{ background: SPHERE[k as Sphere].tone }} />
|
||||
)}
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
<span className="sb-hint">업무/개인은 필터일 뿐 — 작업은 한 곳에서 다 보여요</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
// frontend/components/inbox/TodayRouted.tsx
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { TYPE_LABEL } from "@/lib/inbox/presentation";
|
||||
import type { RouteType, UiInboxItem } from "@/lib/types";
|
||||
|
||||
const DEST_META: Record<RouteType, { tone: string; href: string | null }> = {
|
||||
task: { tone: "green", href: "/tasks" },
|
||||
event: { tone: "blue", href: "/dashboard" },
|
||||
idea: { tone: "violet", href: null },
|
||||
};
|
||||
const ORDER: RouteType[] = ["task", "event", "idea"];
|
||||
|
||||
export default function TodayRouted({
|
||||
items,
|
||||
todayRouted,
|
||||
}: {
|
||||
items: UiInboxItem[];
|
||||
todayRouted: number;
|
||||
}) {
|
||||
const routed = items.filter(
|
||||
(x) => x.classification && (x.status === "confirmed" || x.status === "classified"),
|
||||
);
|
||||
const byType = (t: RouteType) => routed.filter((x) => x.classification!.type === t);
|
||||
|
||||
const subFor = (t: RouteType) => {
|
||||
const g = byType(t);
|
||||
const life = g.filter((x) => x.classification!.sphere === "life").length;
|
||||
const work = g.filter((x) => x.classification!.sphere === "work").length;
|
||||
if (t === "event") return "캘린더 등록";
|
||||
if (t === "idea") return "보드 보관";
|
||||
return `개인 ${life} · 업무 ${work}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="ch">
|
||||
<div className="ico">
|
||||
<Icon name="arrow" />
|
||||
</div>
|
||||
<div className="htext">
|
||||
<h3>오늘 어디로 갔나</h3>
|
||||
<div className="sub">{todayRouted}건의 행선지</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dest-list">
|
||||
{ORDER.map((t) => {
|
||||
const meta = DEST_META[t];
|
||||
const n = byType(t).length;
|
||||
const inner = (
|
||||
<>
|
||||
<span className="dest-dot" style={{ background: `var(--${meta.tone})` }} />
|
||||
<span className="dest-label">{TYPE_LABEL[t]}</span>
|
||||
<span className="dest-sub">{subFor(t)}</span>
|
||||
<span className="dest-n">{n}</span>
|
||||
{meta.href && (
|
||||
<span className="dest-go">
|
||||
<Icon name="arrow" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return meta.href ? (
|
||||
<Link className="dest go" key={t} href={meta.href}>
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="dest" key={t}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="auton-note dim">
|
||||
<Icon name="swap" />
|
||||
분류가 마음에 안 들면 칩을 눌러 바꾸세요 — 아리가 다음부터 기억해요.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
// frontend/lib/inbox/api.ts
|
||||
import type { CaptureResult, ConfirmResult, InboxItem, RouteType } from "@/lib/types";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: string,
|
||||
) {
|
||||
super(`API ${status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function j<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new ApiError(res.status, await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getInbox(): Promise<InboxItem[]> {
|
||||
return j(await fetch(`${BASE}/api/inbox`, { cache: "no-store" }));
|
||||
}
|
||||
|
||||
export async function captureInbox(input: {
|
||||
kind: "text" | "voice" | "image";
|
||||
raw: string;
|
||||
}): Promise<CaptureResult> {
|
||||
return j(
|
||||
await fetch(`${BASE}/api/inbox/capture`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function reclassifyInbox(id: string, type?: RouteType): Promise<CaptureResult> {
|
||||
return j(
|
||||
await fetch(`${BASE}/api/inbox/${id}/reclassify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(type ? { type } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function confirmInbox(id: string): Promise<ConfirmResult> {
|
||||
return j(await fetch(`${BASE}/api/inbox/${id}/confirm`, { method: "POST" }));
|
||||
}
|
||||
|
||||
export async function dismissInbox(id: string): Promise<InboxItem> {
|
||||
return j(await fetch(`${BASE}/api/inbox/${id}/dismiss`, { method: "POST" }));
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
// frontend/lib/inbox/presentation.ts — 분류 결과 → 화면 표현 매핑
|
||||
import type { IconName } from "@/components/icons/paths";
|
||||
import type { RouteType, Sphere } from "@/lib/types";
|
||||
|
||||
/** 원본 sinbox.jsx SPHERE */
|
||||
export const SPHERE: Record<Sphere, { label: string; tone: string }> = {
|
||||
work: { label: "업무", tone: "var(--blue)" },
|
||||
life: { label: "개인", tone: "var(--green)" },
|
||||
};
|
||||
|
||||
/** 원본 sinbox.jsx TYPE_ICON */
|
||||
export const TYPE_ICON: Record<RouteType, IconName> = {
|
||||
task: "check",
|
||||
event: "cal",
|
||||
idea: "brain",
|
||||
};
|
||||
|
||||
export const TYPE_LABEL: Record<RouteType, string> = {
|
||||
task: "작업",
|
||||
event: "일정",
|
||||
idea: "아이디어",
|
||||
};
|
||||
|
||||
/** 원본 cycleType — 다르게 분류 시 다음 타입 */
|
||||
export function nextType(t: RouteType): RouteType {
|
||||
return ({ task: "event", event: "idea", idea: "task" } as const)[t];
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
/* frontend/styles/dash-base.css — 원본 dash.css 의 공유 레이아웃/카드 기본 클래스
|
||||
(인박스·대시보드 공통). 전역/토큰/상단바는 tokens.css·globals.css 가 제공.
|
||||
.ph-title 은 globals.css 에 정의됨(항상 로드). */
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
padding: 14px 4px 22px;
|
||||
}
|
||||
.ph-back {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-brd);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
backdrop-filter: var(--blur);
|
||||
color: var(--ink);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 4px;
|
||||
transition: transform 0.12s;
|
||||
}
|
||||
.ph-back .ic {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
.ph-back:hover {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
.ph-eyebrow {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 7px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.ph-eyebrow .wx {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.ph-eyebrow .wx .ic {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--amber);
|
||||
}
|
||||
.ph-eyebrow .sep {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--faint);
|
||||
}
|
||||
.ph-title em {
|
||||
font-style: normal;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ph-search {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 250px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-brd);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
backdrop-filter: var(--blur);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 4px;
|
||||
transition: box-shadow 0.18s, border-color 0.18s;
|
||||
}
|
||||
.ph-search:focus-within {
|
||||
border-color: var(--line-2);
|
||||
box-shadow: 0 0 0 4px rgba(79, 114, 224, 0.12);
|
||||
}
|
||||
.ph-search .ic {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ph-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.ph-search input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* 작업 영역 + 보드 */
|
||||
.work {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.board {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card {
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow), var(--glass-hi);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.card.sp2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.card.r2 {
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
/* 카드 헤더 */
|
||||
.ch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ch .ico {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--glass-2);
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--glass-brd);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ch .ico .ic {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
.ch .htext {
|
||||
min-width: 0;
|
||||
}
|
||||
.ch h3 {
|
||||
font-size: 15.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ch .sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.ch .tool {
|
||||
margin-left: auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.ch .tool .ic {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.ch .tool:hover {
|
||||
background: var(--card-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.ch .count {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--ink-2);
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
|
||||
/* 캡처 종류 아이콘 / 분류 칩 / 시간 (인박스·대시보드 공유) */
|
||||
.cap-k {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.cap-k .ic {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.cap-k.voice {
|
||||
color: var(--violet);
|
||||
}
|
||||
.cap-k.image {
|
||||
color: var(--blue);
|
||||
}
|
||||
.cap-k.text {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.r-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--ink-2);
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.r-chip .ic {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.r-chip .pdot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.r-chip.proj {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cap-time {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* 자율성 노트 (원본 approve.css) */
|
||||
.auton-note {
|
||||
margin-top: 13px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-2);
|
||||
line-height: 1.5;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.auton-note .ic {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--coral);
|
||||
}
|
||||
.auton-note.dim {
|
||||
color: var(--muted);
|
||||
}
|
||||
.auton-note.dim .ic {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 보드 반응형 (원본 786~795) */
|
||||
@media (max-width: 1200px) {
|
||||
.board {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.card.sp2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,474 @@
|
||||
/* frontend/styles/inbox.css — 원본 sinbox.css 이식 + 신설 클래스
|
||||
(공유 dash 기본 클래스는 dash-base.css) */
|
||||
@import "./dash-base.css";
|
||||
|
||||
/* ---------- 입력 ---------- */
|
||||
.sb-cmd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--glass-brd);
|
||||
border-radius: 15px;
|
||||
padding: 6px 6px 6px 15px;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
.sb-cmd > svg:first-child {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--coral);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-cmd input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.sb-cmd input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
.sb-mode {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
transition: background 0.13s, color 0.13s;
|
||||
}
|
||||
.sb-mode .ic {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.sb-mode:hover {
|
||||
background: var(--card-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sb-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 11px;
|
||||
flex-shrink: 0;
|
||||
background: var(--fill);
|
||||
color: var(--on-fill);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: filter 0.14s;
|
||||
}
|
||||
.sb-send .ic {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.sb-send:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
/* ---------- 컨텍스트 필터 ---------- */
|
||||
.sb-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sb-f {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--glass-brd);
|
||||
background: var(--glass-2);
|
||||
transition: background 0.13s, color 0.13s;
|
||||
}
|
||||
.sb-f .fdot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.sb-f:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
.sb-f.on {
|
||||
background: var(--fill);
|
||||
color: var(--on-fill);
|
||||
border-color: transparent;
|
||||
}
|
||||
.sb-hint {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---------- 목록 ---------- */
|
||||
.sb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sb-cap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.sb-cap:first-child {
|
||||
border-top: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.sb-cap.fresh {
|
||||
margin: 0 -10px;
|
||||
padding: 13px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-top: none;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
color-mix(in oklab, var(--coral) 9%, transparent),
|
||||
transparent 65%
|
||||
),
|
||||
var(--glass-2);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--coral) 26%, var(--glass-brd));
|
||||
}
|
||||
.sb-cap.fresh + .sb-cap {
|
||||
border-top: none;
|
||||
}
|
||||
.sb-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sb-raw {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.sb-route {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.sb-route .r-arrow {
|
||||
color: var(--coral);
|
||||
display: inline-flex;
|
||||
}
|
||||
.sb-route .r-arrow .ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.r-chip.type {
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.r-chip.type .ic {
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.r-chip.type.event {
|
||||
color: var(--blue);
|
||||
border-color: color-mix(in oklab, var(--blue) 30%, var(--glass-brd));
|
||||
}
|
||||
.r-chip.type.event .ic {
|
||||
color: var(--blue);
|
||||
}
|
||||
.r-chip.type.idea {
|
||||
color: var(--violet);
|
||||
border-color: color-mix(in oklab, var(--violet) 30%, var(--glass-brd));
|
||||
}
|
||||
.r-chip.type.idea .ic {
|
||||
color: var(--violet);
|
||||
}
|
||||
.r-chip.type:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.r-chip.sphere {
|
||||
font-weight: 700;
|
||||
}
|
||||
.r-chip.auto {
|
||||
color: var(--amber);
|
||||
font-weight: 700;
|
||||
border-color: color-mix(in oklab, var(--amber) 36%, var(--glass-brd));
|
||||
background: color-mix(in oklab, var(--amber) 9%, transparent);
|
||||
}
|
||||
.r-chip.auto .ic {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.sb-reason {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 7px;
|
||||
max-width: 62ch;
|
||||
}
|
||||
.sb-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--amber);
|
||||
margin-right: 6px;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in oklab, var(--amber) 36%, var(--glass-brd));
|
||||
background: color-mix(in oklab, var(--amber) 9%, transparent);
|
||||
}
|
||||
.sb-fallback .ic {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 확인 액션 ---------- */
|
||||
.sb-acts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.sb-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--lime-ink);
|
||||
background: var(--lime);
|
||||
padding: 7px 13px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 12px -6px color-mix(in oklab, var(--lime-ink) 50%, transparent);
|
||||
transition: background 0.13s, transform 0.12s;
|
||||
}
|
||||
.sb-ok .ic {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.sb-ok:hover {
|
||||
background: var(--lime-hi);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.sb-alt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
transition: color 0.13s, border-color 0.13s;
|
||||
}
|
||||
.sb-alt .ic {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.sb-alt:hover {
|
||||
color: var(--ink);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
|
||||
/* ---------- 생각 중 ---------- */
|
||||
.sb-think {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 9px;
|
||||
}
|
||||
.sb-think .tdot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--coral);
|
||||
animation: sb-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.sb-think .tdot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.sb-think .tdot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
@keyframes sb-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.sb-done {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in oklab, var(--green) 16%, transparent);
|
||||
color: var(--green);
|
||||
}
|
||||
.sb-done .ic {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
/* 빈 상태 / 에러 (신설) */
|
||||
.sb-empty {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
padding: 16px 2px;
|
||||
}
|
||||
.sb-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--coral);
|
||||
margin-top: 9px;
|
||||
}
|
||||
.sb-retry {
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.inbox-loaderr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in oklab, var(--coral) 8%, var(--card));
|
||||
border: 1px solid color-mix(in oklab, var(--coral) 26%, transparent);
|
||||
color: var(--ink-2);
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ===== 분류 원칙 + 행선지 ===== */
|
||||
.prin-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.prin {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
align-items: flex-start;
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--glass-brd);
|
||||
background: var(--glass-2);
|
||||
}
|
||||
.prin-ic {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in oklab, var(--tone) 14%, transparent);
|
||||
color: var(--tone);
|
||||
}
|
||||
.prin-ic .ic {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.prin-body {
|
||||
min-width: 0;
|
||||
}
|
||||
.prin-title {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.prin-desc {
|
||||
display: block;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 3px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dest-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.dest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 2px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.dest:first-child {
|
||||
border-top: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.dest-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dest-label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.dest-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.dest-n {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.dest-go {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
transition: background 0.13s, color 0.13s, transform 0.13s;
|
||||
}
|
||||
.dest-go .ic {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.dest.go:hover .dest-go {
|
||||
background: var(--card-2);
|
||||
color: var(--ink);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sb-think .tdot {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
// frontend/tests/inbox/CaptureComposer.test.tsx
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import CaptureComposer from "@/components/inbox/CaptureComposer";
|
||||
|
||||
describe("CaptureComposer", () => {
|
||||
it("Enter로 text 제출 후 입력 비움", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CaptureComposer onSubmit={onSubmit} />);
|
||||
const input = screen.getByLabelText("인박스에 빠르게 캡처") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "엄마 생신 선물 알아보기" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSubmit).toHaveBeenCalledWith("엄마 생신 선물 알아보기", "text");
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
it("mic 버튼: voice kind + 더미 transcript", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CaptureComposer onSubmit={onSubmit} />);
|
||||
fireEvent.click(screen.getByLabelText(/음성으로 캡처/));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.stringContaining("음성 메모"), "voice");
|
||||
});
|
||||
|
||||
it("image 버튼: image kind", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CaptureComposer onSubmit={onSubmit} />);
|
||||
fireEvent.click(screen.getByLabelText(/이미지로 캡처/));
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.any(String), "image");
|
||||
});
|
||||
|
||||
it("빈 입력은 제출 무시", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<CaptureComposer onSubmit={onSubmit} />);
|
||||
fireEvent.keyDown(screen.getByLabelText("인박스에 빠르게 캡처"), { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
// frontend/tests/inbox/inbox-presentation.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SPHERE, TYPE_ICON, TYPE_LABEL, nextType } from "@/lib/inbox/presentation";
|
||||
|
||||
describe("inbox-presentation", () => {
|
||||
it("nextType 순환 task→event→idea→task", () => {
|
||||
expect(nextType("task")).toBe("event");
|
||||
expect(nextType("event")).toBe("idea");
|
||||
expect(nextType("idea")).toBe("task");
|
||||
});
|
||||
it("라벨/아이콘/sphere 맵", () => {
|
||||
expect(TYPE_LABEL.task).toBe("작업");
|
||||
expect(TYPE_LABEL.event).toBe("일정");
|
||||
expect(TYPE_LABEL.idea).toBe("아이디어");
|
||||
expect(TYPE_ICON.idea).toBe("brain");
|
||||
expect(TYPE_ICON.task).toBe("check");
|
||||
expect(SPHERE.life.label).toBe("개인");
|
||||
expect(SPHERE.work.label).toBe("업무");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue