- GET /rd/design-insight 라우트 + 컨트롤러 추가 - Alpine.js 단일 파일 SPA (localStorage 기반) - 4종 카드: 레퍼런스, 분석(CRAP), 패턴, Before/After - 3종 뷰: 보드, 갤러리, 리스트 - Ctrl+V 클립보드 이미지 붙여넣기 - 프로젝트 CRUD, 태그/카테고리 필터, 검색 - JSON 내보내기/가져오기
1700 lines
68 KiB
PHP
1700 lines
68 KiB
PHP
@extends('layouts.app')
|
||
|
||
@section('title', '디자인 인사이트')
|
||
|
||
@section('content')
|
||
<style>
|
||
/* ===== Design Insight Core ===== */
|
||
:root {
|
||
--di-sidebar: 280px;
|
||
--di-toolbar: 48px;
|
||
--di-blue: #3b82f6;
|
||
--di-indigo: #6366f1;
|
||
--di-green: #10b981;
|
||
--di-amber: #f59e0b;
|
||
--di-red: #ef4444;
|
||
--di-purple: #8b5cf6;
|
||
--di-pink: #ec4899;
|
||
--di-cyan: #0ea5e9;
|
||
--di-slate: #64748b;
|
||
--di-bg: #f8fafc;
|
||
--di-card-bg: #ffffff;
|
||
--di-border: #e2e8f0;
|
||
--di-text: #1e293b;
|
||
--di-text-secondary: #64748b;
|
||
--di-radius: 10px;
|
||
--di-shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.04);
|
||
--di-shadow-lg: 0 4px 12px rgba(0,0,0,.1), 0 2px 4px rgba(0,0,0,.06);
|
||
}
|
||
|
||
/* Wrap */
|
||
.di-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: calc(100vh - 56px);
|
||
background: var(--di-bg);
|
||
overflow: hidden;
|
||
font-family: 'Pretendard', -apple-system, sans-serif;
|
||
}
|
||
|
||
/* Toolbar */
|
||
.di-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
height: var(--di-toolbar);
|
||
padding: 0 16px;
|
||
background: #fff;
|
||
border-bottom: 1px solid var(--di-border);
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
z-index: 20;
|
||
}
|
||
.di-toolbar .di-title-input {
|
||
border: none;
|
||
background: transparent;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--di-text);
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
min-width: 200px;
|
||
}
|
||
.di-toolbar .di-title-input:hover { background: #f1f5f9; }
|
||
.di-toolbar .di-title-input:focus { outline: 2px solid var(--di-blue); background: #fff; }
|
||
.di-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
font-size: 12.5px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
border: 1px solid var(--di-border);
|
||
background: #fff;
|
||
color: var(--di-text);
|
||
transition: all .15s;
|
||
white-space: nowrap;
|
||
}
|
||
.di-btn:hover { background: #f1f5f9; border-color: #cbd5e1; }
|
||
.di-btn.primary { background: var(--di-blue); color: #fff; border-color: var(--di-blue); }
|
||
.di-btn.primary:hover { background: #2563eb; }
|
||
.di-btn.sm { padding: 4px 8px; font-size: 11.5px; }
|
||
.di-btn.ghost { border: none; background: transparent; }
|
||
.di-btn.ghost:hover { background: #f1f5f9; }
|
||
.di-view-tabs {
|
||
display: flex;
|
||
gap: 2px;
|
||
background: #f1f5f9;
|
||
border-radius: 8px;
|
||
padding: 3px;
|
||
}
|
||
.di-view-tab {
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
color: var(--di-text-secondary);
|
||
transition: all .15s;
|
||
border: none;
|
||
background: transparent;
|
||
}
|
||
.di-view-tab:hover { color: var(--di-text); }
|
||
.di-view-tab.active { background: #fff; color: var(--di-text); box-shadow: 0 1px 2px rgba(0,0,0,.08); font-weight: 600; }
|
||
|
||
/* Body */
|
||
.di-body {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.di-sidebar {
|
||
width: var(--di-sidebar);
|
||
background: #fff;
|
||
border-right: 1px solid var(--di-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex-shrink: 0;
|
||
overflow-y: auto;
|
||
transition: width .2s;
|
||
}
|
||
.di-sidebar.collapsed { width: 0; overflow: hidden; padding: 0; border-right: none; }
|
||
.di-sidebar-section {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--di-border);
|
||
}
|
||
.di-sidebar-section h4 {
|
||
font-size: 10.5px;
|
||
font-weight: 700;
|
||
color: var(--di-text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.di-sidebar-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 7px 10px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: var(--di-text);
|
||
cursor: pointer;
|
||
transition: all .12s;
|
||
}
|
||
.di-sidebar-item:hover { background: #f1f5f9; }
|
||
.di-sidebar-item.active { background: #eff6ff; color: var(--di-blue); font-weight: 600; }
|
||
.di-sidebar-item .cnt {
|
||
margin-left: auto;
|
||
font-size: 11px;
|
||
color: var(--di-text-secondary);
|
||
background: #f1f5f9;
|
||
padding: 1px 7px;
|
||
border-radius: 10px;
|
||
}
|
||
.di-tag-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 3px 10px;
|
||
border-radius: 20px;
|
||
font-size: 11.5px;
|
||
cursor: pointer;
|
||
border: 1px solid var(--di-border);
|
||
background: #fff;
|
||
color: var(--di-text-secondary);
|
||
margin: 2px;
|
||
transition: all .12s;
|
||
}
|
||
.di-tag-chip:hover { border-color: var(--di-blue); color: var(--di-blue); }
|
||
.di-tag-chip.active { background: var(--di-blue); color: #fff; border-color: var(--di-blue); }
|
||
.di-search-input {
|
||
width: 100%;
|
||
padding: 7px 10px 7px 32px;
|
||
border: 1px solid var(--di-border);
|
||
border-radius: 8px;
|
||
font-size: 12.5px;
|
||
background: #f8fafc;
|
||
transition: all .15s;
|
||
}
|
||
.di-search-input:focus { outline: none; border-color: var(--di-blue); background: #fff; box-shadow: 0 0 0 3px rgba(59,130,246,.1); }
|
||
|
||
/* Main */
|
||
.di-main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Category Tabs */
|
||
.di-cat-tabs {
|
||
display: flex;
|
||
gap: 1px;
|
||
padding: 10px 20px 0;
|
||
background: #fff;
|
||
border-bottom: 1px solid var(--di-border);
|
||
overflow-x: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
.di-cat-tab {
|
||
padding: 8px 16px;
|
||
font-size: 13px;
|
||
color: var(--di-text-secondary);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all .15s;
|
||
white-space: nowrap;
|
||
background: none;
|
||
border-top: none;
|
||
border-left: none;
|
||
border-right: none;
|
||
}
|
||
.di-cat-tab:hover { color: var(--di-text); }
|
||
.di-cat-tab.active { color: var(--di-blue); border-bottom-color: var(--di-blue); font-weight: 600; }
|
||
|
||
/* Content */
|
||
.di-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* Board View */
|
||
.di-board {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
/* Card */
|
||
.di-card {
|
||
background: var(--di-card-bg);
|
||
border-radius: var(--di-radius);
|
||
border: 1px solid var(--di-border);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
position: relative;
|
||
}
|
||
.di-card:hover { box-shadow: var(--di-shadow-lg); border-color: #cbd5e1; transform: translateY(-1px); }
|
||
.di-card .card-img {
|
||
width: 100%;
|
||
height: 180px;
|
||
object-fit: cover;
|
||
background: #f1f5f9;
|
||
display: block;
|
||
}
|
||
.di-card .card-img-placeholder {
|
||
width: 100%;
|
||
height: 180px;
|
||
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #94a3b8;
|
||
font-size: 32px;
|
||
}
|
||
.di-card .card-body {
|
||
padding: 14px;
|
||
}
|
||
.di-card .card-type {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10.5px;
|
||
font-weight: 600;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.di-card .card-type.ref { background: #eff6ff; color: #2563eb; }
|
||
.di-card .card-type.analysis { background: #fef3c7; color: #d97706; }
|
||
.di-card .card-type.pattern { background: #ecfdf5; color: #059669; }
|
||
.di-card .card-type.comparison { background: #fae8ff; color: #a855f7; }
|
||
.di-card .card-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--di-text);
|
||
margin-bottom: 6px;
|
||
line-height: 1.4;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.di-card .card-memo {
|
||
font-size: 12.5px;
|
||
color: var(--di-text-secondary);
|
||
line-height: 1.5;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
margin-bottom: 8px;
|
||
}
|
||
.di-card .card-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.di-card .card-tag {
|
||
font-size: 10.5px;
|
||
padding: 2px 7px;
|
||
border-radius: 4px;
|
||
background: #f1f5f9;
|
||
color: var(--di-text-secondary);
|
||
}
|
||
.di-card .card-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding-top: 8px;
|
||
border-top: 1px solid #f1f5f9;
|
||
}
|
||
.di-card .card-rating { color: #f59e0b; font-size: 12px; letter-spacing: 1px; }
|
||
.di-card .card-date { font-size: 11px; color: #94a3b8; }
|
||
.di-card .card-pin {
|
||
position: absolute;
|
||
top: 8px;
|
||
right: 8px;
|
||
background: rgba(255,255,255,.9);
|
||
border-radius: 50%;
|
||
width: 28px;
|
||
height: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transition: opacity .15s;
|
||
border: 1px solid var(--di-border);
|
||
}
|
||
.di-card:hover .card-pin { opacity: 1; }
|
||
.di-card .card-pin.pinned { opacity: 1; color: var(--di-amber); }
|
||
|
||
/* Add Card Button */
|
||
.di-add-card {
|
||
background: var(--di-card-bg);
|
||
border-radius: var(--di-radius);
|
||
border: 2px dashed var(--di-border);
|
||
min-height: 200px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
gap: 8px;
|
||
color: var(--di-text-secondary);
|
||
}
|
||
.di-add-card:hover { border-color: var(--di-blue); color: var(--di-blue); background: #f0f7ff; }
|
||
.di-add-card i { font-size: 28px; }
|
||
.di-add-card span { font-size: 13px; font-weight: 500; }
|
||
|
||
/* Gallery View */
|
||
.di-gallery {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||
gap: 12px;
|
||
}
|
||
.di-gallery .di-card .card-img { height: 220px; }
|
||
.di-gallery .di-card .card-body { padding: 10px; }
|
||
|
||
/* List View */
|
||
.di-list { display: flex; flex-direction: column; gap: 4px; }
|
||
.di-list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
padding: 10px 14px;
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--di-border);
|
||
cursor: pointer;
|
||
transition: all .12s;
|
||
}
|
||
.di-list-item:hover { background: #f8fafc; box-shadow: var(--di-shadow); }
|
||
.di-list-item .li-thumb {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 6px;
|
||
object-fit: cover;
|
||
background: #f1f5f9;
|
||
flex-shrink: 0;
|
||
}
|
||
.di-list-item .li-thumb-empty {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 6px;
|
||
background: #f1f5f9;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #94a3b8;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
.di-list-item .li-info { flex: 1; min-width: 0; }
|
||
.di-list-item .li-title { font-size: 13.5px; font-weight: 600; color: var(--di-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.di-list-item .li-memo { font-size: 12px; color: var(--di-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.di-list-item .li-tags { display: flex; gap: 4px; flex-shrink: 0; }
|
||
.di-list-item .li-date { font-size: 11px; color: #94a3b8; flex-shrink: 0; }
|
||
|
||
/* Modal */
|
||
.di-modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,.4);
|
||
z-index: 100;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
.di-modal {
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
width: 680px;
|
||
max-width: 95vw;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||
}
|
||
.di-modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--di-border);
|
||
}
|
||
.di-modal-header h3 { font-size: 16px; font-weight: 700; color: var(--di-text); }
|
||
.di-modal-body { padding: 20px; }
|
||
.di-modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--di-border);
|
||
}
|
||
.di-field { margin-bottom: 16px; }
|
||
.di-field label {
|
||
display: block;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
color: var(--di-text);
|
||
margin-bottom: 5px;
|
||
}
|
||
.di-field input[type="text"],
|
||
.di-field textarea,
|
||
.di-field select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--di-border);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
transition: all .15s;
|
||
background: #fff;
|
||
}
|
||
.di-field input:focus, .di-field textarea:focus, .di-field select:focus {
|
||
outline: none;
|
||
border-color: var(--di-blue);
|
||
box-shadow: 0 0 0 3px rgba(59,130,246,.1);
|
||
}
|
||
.di-field textarea { min-height: 80px; resize: vertical; }
|
||
|
||
/* Image Drop Zone */
|
||
.di-drop-zone {
|
||
border: 2px dashed var(--di-border);
|
||
border-radius: 10px;
|
||
padding: 30px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
background: #fafbfc;
|
||
}
|
||
.di-drop-zone:hover, .di-drop-zone.dragover {
|
||
border-color: var(--di-blue);
|
||
background: #eff6ff;
|
||
}
|
||
.di-drop-zone img {
|
||
max-width: 100%;
|
||
max-height: 250px;
|
||
object-fit: contain;
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* Before/After comparison */
|
||
.di-comparison {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
}
|
||
.di-comparison .comp-side {
|
||
border: 2px dashed var(--di-border);
|
||
border-radius: 10px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all .2s;
|
||
background: #fafbfc;
|
||
min-height: 150px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.di-comparison .comp-side:hover { border-color: var(--di-blue); background: #eff6ff; }
|
||
.di-comparison .comp-side img { max-width: 100%; max-height: 200px; object-fit: contain; border-radius: 8px; }
|
||
.di-comparison .comp-label {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
padding: 3px 10px;
|
||
border-radius: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.di-comparison .comp-before .comp-label { background: #fef2f2; color: #dc2626; }
|
||
.di-comparison .comp-after .comp-label { background: #ecfdf5; color: #059669; }
|
||
|
||
/* CRAP Checklist */
|
||
.di-crap-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 8px;
|
||
}
|
||
.di-crap-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--di-border);
|
||
cursor: pointer;
|
||
transition: all .12s;
|
||
font-size: 12.5px;
|
||
}
|
||
.di-crap-item:hover { background: #f8fafc; }
|
||
.di-crap-item.pass { background: #ecfdf5; border-color: #a7f3d0; }
|
||
.di-crap-item.fail { background: #fef2f2; border-color: #fecaca; }
|
||
.di-crap-item.warn { background: #fffbeb; border-color: #fde68a; }
|
||
|
||
/* Rating Stars */
|
||
.di-stars {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
.di-star {
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
color: #e2e8f0;
|
||
transition: color .1s;
|
||
}
|
||
.di-star.filled { color: #f59e0b; }
|
||
.di-star:hover { color: #fbbf24; }
|
||
|
||
/* Status Bar */
|
||
.di-statusbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 6px 20px;
|
||
background: #fff;
|
||
border-top: 1px solid var(--di-border);
|
||
font-size: 11.5px;
|
||
color: var(--di-text-secondary);
|
||
flex-shrink: 0;
|
||
}
|
||
.di-statusbar .stat { display: flex; align-items: center; gap: 4px; }
|
||
|
||
/* Toast */
|
||
.di-toast {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
background: #1e293b;
|
||
color: #fff;
|
||
padding: 10px 18px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
z-index: 200;
|
||
box-shadow: 0 8px 20px rgba(0,0,0,.2);
|
||
animation: diToastIn .3s ease;
|
||
}
|
||
@keyframes diToastIn { from { opacity: 0; transform: translateY(10px); } }
|
||
|
||
/* Empty State */
|
||
.di-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 60px 20px;
|
||
color: var(--di-text-secondary);
|
||
text-align: center;
|
||
}
|
||
.di-empty i { font-size: 48px; margin-bottom: 16px; color: #cbd5e1; }
|
||
.di-empty h3 { font-size: 16px; font-weight: 600; color: var(--di-text); margin-bottom: 6px; }
|
||
.di-empty p { font-size: 13px; margin-bottom: 16px; }
|
||
|
||
/* Paste hint */
|
||
.di-paste-hint {
|
||
position: fixed;
|
||
bottom: 80px;
|
||
right: 24px;
|
||
background: #1e293b;
|
||
color: #fff;
|
||
padding: 8px 14px;
|
||
border-radius: 8px;
|
||
font-size: 12px;
|
||
z-index: 50;
|
||
opacity: .8;
|
||
}
|
||
.di-paste-hint kbd {
|
||
background: rgba(255,255,255,.2);
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* Projects Modal */
|
||
.di-proj-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: all .12s;
|
||
border: 1px solid transparent;
|
||
}
|
||
.di-proj-item:hover { background: #f8fafc; border-color: var(--di-border); }
|
||
.di-proj-item.active { background: #eff6ff; border-color: var(--di-blue); }
|
||
.di-proj-item .proj-title { font-size: 13.5px; font-weight: 600; flex: 1; }
|
||
.di-proj-item .proj-count { font-size: 11px; color: var(--di-text-secondary); }
|
||
.di-proj-item .proj-date { font-size: 11px; color: #94a3b8; }
|
||
</style>
|
||
|
||
<!-- ===== Alpine.js App ===== -->
|
||
<div class="di-wrap" x-data="designInsight()" x-init="init()" @paste.window="handlePaste($event)" @keydown.window="handleKeydown($event)">
|
||
|
||
<!-- Toolbar -->
|
||
<div class="di-toolbar">
|
||
<button class="di-btn ghost" @click="sidebarOpen = !sidebarOpen" title="사이드바 토글">
|
||
<i class="ri-layout-left-line" style="font-size: 16px;"></i>
|
||
</button>
|
||
|
||
<input type="text" class="di-title-input"
|
||
x-model="currentProject.title"
|
||
@change="saveProject()"
|
||
placeholder="프로젝트 제목">
|
||
|
||
<div style="flex: 1;"></div>
|
||
|
||
<button class="di-btn sm" @click="saveProject()" title="저장 (Ctrl+S)">
|
||
<i class="ri-save-line"></i> 저장
|
||
</button>
|
||
<button class="di-btn sm" @click="showExportMenu = !showExportMenu" title="내보내기">
|
||
<i class="ri-download-line"></i> 내보내기
|
||
</button>
|
||
<template x-if="showExportMenu">
|
||
<div style="position: absolute; top: 44px; right: 80px; background: #fff; border: 1px solid var(--di-border); border-radius: 8px; box-shadow: var(--di-shadow-lg); z-index: 30; padding: 4px;"
|
||
@click.outside="showExportMenu = false">
|
||
<button class="di-btn ghost sm" style="width: 100%; justify-content: flex-start;" @click="exportJSON(); showExportMenu=false">
|
||
<i class="ri-code-s-slash-line"></i> JSON 내보내기
|
||
</button>
|
||
<button class="di-btn ghost sm" style="width: 100%; justify-content: flex-start;" @click="importJSON(); showExportMenu=false">
|
||
<i class="ri-upload-line"></i> JSON 가져오기
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="di-view-tabs">
|
||
<button class="di-view-tab" :class="viewMode === 'board' && 'active'" @click="viewMode = 'board'" title="보드 뷰">
|
||
<i class="ri-layout-grid-line"></i>
|
||
</button>
|
||
<button class="di-view-tab" :class="viewMode === 'gallery' && 'active'" @click="viewMode = 'gallery'" title="갤러리 뷰">
|
||
<i class="ri-image-line"></i>
|
||
</button>
|
||
<button class="di-view-tab" :class="viewMode === 'list' && 'active'" @click="viewMode = 'list'" title="리스트 뷰">
|
||
<i class="ri-list-unordered"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<button class="di-btn sm" @click="showProjectsModal = true" title="프로젝트 관리">
|
||
<i class="ri-folder-line"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Category Tabs -->
|
||
<div class="di-cat-tabs">
|
||
<button class="di-cat-tab" :class="categoryFilter === 'all' && 'active'" @click="categoryFilter = 'all'">
|
||
전체 <span x-text="'(' + filteredCards.length + ')'" style="font-size: 11px; color: #94a3b8; margin-left: 2px;"></span>
|
||
</button>
|
||
<template x-for="type in cardTypes" :key="type.code">
|
||
<button class="di-cat-tab" :class="categoryFilter === type.code && 'active'" @click="categoryFilter = type.code">
|
||
<span x-text="type.icon + ' ' + type.label"></span>
|
||
<span x-text="'(' + getCardCountByType(type.code) + ')'" style="font-size: 11px; color: #94a3b8; margin-left: 2px;"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Body -->
|
||
<div class="di-body">
|
||
|
||
<!-- Sidebar -->
|
||
<div class="di-sidebar" :class="!sidebarOpen && 'collapsed'">
|
||
<!-- Search -->
|
||
<div class="di-sidebar-section">
|
||
<div style="position: relative;">
|
||
<i class="ri-search-line" style="position: absolute; left: 10px; top: 8px; font-size: 14px; color: #94a3b8;"></i>
|
||
<input type="text" class="di-search-input" x-model="searchQuery" placeholder="검색..." @keyup.escape="searchQuery = ''">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Categories -->
|
||
<div class="di-sidebar-section">
|
||
<h4>카테고리</h4>
|
||
<div class="di-sidebar-item" :class="screenFilter === 'all' && 'active'" @click="screenFilter = 'all'">
|
||
<span>전체</span>
|
||
<span class="cnt" x-text="currentProject.cards?.length || 0"></span>
|
||
</div>
|
||
<template x-for="cat in categories" :key="cat.code">
|
||
<div class="di-sidebar-item" :class="screenFilter === cat.code && 'active'" @click="screenFilter = cat.code">
|
||
<span x-text="cat.icon + ' ' + cat.label"></span>
|
||
<span class="cnt" x-text="getCardCountByCat(cat.code)"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Tags -->
|
||
<div class="di-sidebar-section">
|
||
<h4>태그</h4>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 2px;">
|
||
<template x-for="tag in allTags" :key="tag">
|
||
<span class="di-tag-chip" :class="selectedTags.includes(tag) && 'active'"
|
||
@click="toggleTag(tag)" x-text="tag"></span>
|
||
</template>
|
||
<template x-if="allTags.length === 0">
|
||
<span style="font-size: 12px; color: #94a3b8;">태그 없음</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sort -->
|
||
<div class="di-sidebar-section">
|
||
<h4>정렬</h4>
|
||
<select style="width: 100%; padding: 6px 8px; border: 1px solid var(--di-border); border-radius: 6px; font-size: 12px;" x-model="sortBy" @change="$nextTick()">
|
||
<option value="newest">최신순</option>
|
||
<option value="oldest">오래된순</option>
|
||
<option value="rating">평점순</option>
|
||
<option value="title">이름순</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Main Content -->
|
||
<div class="di-main">
|
||
<div class="di-content">
|
||
|
||
<!-- Empty State -->
|
||
<template x-if="filteredCards.length === 0 && !searchQuery && selectedTags.length === 0 && screenFilter === 'all' && categoryFilter === 'all'">
|
||
<div class="di-empty">
|
||
<i class="ri-palette-line"></i>
|
||
<h3>인사이트를 수집해보세요</h3>
|
||
<p><kbd>Ctrl+V</kbd>로 스크린샷을 붙여넣거나, 아래 버튼으로 시작하세요</p>
|
||
<button class="di-btn primary" @click="openNewCardModal('reference')">
|
||
<i class="ri-add-line"></i> 첫 번째 카드 추가
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- No Results -->
|
||
<template x-if="filteredCards.length === 0 && (searchQuery || selectedTags.length > 0 || screenFilter !== 'all' || categoryFilter !== 'all')">
|
||
<div class="di-empty">
|
||
<i class="ri-search-line"></i>
|
||
<h3>검색 결과 없음</h3>
|
||
<p>필터를 변경하거나 검색어를 수정하세요</p>
|
||
<button class="di-btn" @click="clearFilters()">
|
||
<i class="ri-refresh-line"></i> 필터 초기화
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Board View -->
|
||
<template x-if="viewMode === 'board' && filteredCards.length > 0">
|
||
<div class="di-board">
|
||
<template x-for="card in filteredCards" :key="card.id">
|
||
<div class="di-card" @click="openEditCardModal(card)">
|
||
<!-- Pin -->
|
||
<div class="card-pin" :class="card.pinned && 'pinned'"
|
||
@click.stop="togglePin(card)" x-text="card.pinned ? '📌' : '📌'"></div>
|
||
|
||
<!-- Image -->
|
||
<template x-if="card.type === 'comparison'">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; height: 180px;">
|
||
<template x-if="card.beforeImage">
|
||
<img :src="card.beforeImage" style="width: 100%; height: 180px; object-fit: cover; border-right: 1px solid var(--di-border);">
|
||
</template>
|
||
<template x-if="!card.beforeImage">
|
||
<div class="card-img-placeholder" style="border-right: 1px solid var(--di-border);"><i class="ri-arrow-left-line"></i></div>
|
||
</template>
|
||
<template x-if="card.afterImage">
|
||
<img :src="card.afterImage" style="width: 100%; height: 180px; object-fit: cover;">
|
||
</template>
|
||
<template x-if="!card.afterImage">
|
||
<div class="card-img-placeholder"><i class="ri-arrow-right-line"></i></div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="card.type !== 'comparison' && card.image">
|
||
<img :src="card.image" class="card-img">
|
||
</template>
|
||
<template x-if="card.type !== 'comparison' && !card.image">
|
||
<div class="card-img-placeholder">
|
||
<i :class="getTypeIcon(card.type)"></i>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Body -->
|
||
<div class="card-body">
|
||
<span class="card-type" :class="card.type === 'reference' ? 'ref' : card.type">
|
||
<span x-text="getTypeLabel(card.type)"></span>
|
||
</span>
|
||
<div class="card-title" x-text="card.title || '(제목 없음)'"></div>
|
||
<div class="card-memo" x-text="card.memo || card.suggestion || card.effect || ''"></div>
|
||
<div class="card-tags" x-show="card.tags && card.tags.length > 0">
|
||
<template x-for="tag in (card.tags || []).slice(0, 4)" :key="tag">
|
||
<span class="card-tag" x-text="tag"></span>
|
||
</template>
|
||
<template x-if="(card.tags || []).length > 4">
|
||
<span class="card-tag" x-text="'+' + ((card.tags || []).length - 4)"></span>
|
||
</template>
|
||
</div>
|
||
<div class="card-footer">
|
||
<div class="card-rating" x-text="getRatingStars(card.rating || 0)"></div>
|
||
<div class="card-date" x-text="formatDate(card.createdAt)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Add New Card -->
|
||
<div class="di-add-card" @click="openNewCardModal('reference')">
|
||
<i class="ri-add-circle-line"></i>
|
||
<span>새 카드 추가</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Gallery View -->
|
||
<template x-if="viewMode === 'gallery' && filteredCards.length > 0">
|
||
<div class="di-gallery">
|
||
<template x-for="card in filteredCards" :key="card.id">
|
||
<div class="di-card" @click="openEditCardModal(card)">
|
||
<template x-if="card.image">
|
||
<img :src="card.image" class="card-img">
|
||
</template>
|
||
<template x-if="!card.image && card.beforeImage">
|
||
<img :src="card.beforeImage" class="card-img">
|
||
</template>
|
||
<template x-if="!card.image && !card.beforeImage">
|
||
<div class="card-img-placeholder"><i :class="getTypeIcon(card.type)"></i></div>
|
||
</template>
|
||
<div class="card-body">
|
||
<div class="card-title" x-text="card.title || '(제목 없음)'"></div>
|
||
<div class="card-rating" x-text="getRatingStars(card.rating || 0)"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<div class="di-add-card" @click="openNewCardModal('reference')" style="min-height: 260px;">
|
||
<i class="ri-add-circle-line"></i>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- List View -->
|
||
<template x-if="viewMode === 'list' && filteredCards.length > 0">
|
||
<div class="di-list">
|
||
<template x-for="card in filteredCards" :key="card.id">
|
||
<div class="di-list-item" @click="openEditCardModal(card)">
|
||
<template x-if="card.image">
|
||
<img :src="card.image" class="li-thumb">
|
||
</template>
|
||
<template x-if="!card.image">
|
||
<div class="li-thumb-empty"><i :class="getTypeIcon(card.type)"></i></div>
|
||
</template>
|
||
<div class="li-info">
|
||
<div class="li-title">
|
||
<span class="card-type" :class="card.type === 'reference' ? 'ref' : card.type"
|
||
x-text="getTypeLabel(card.type)" style="font-size: 10px; margin-right: 6px;"></span>
|
||
<span x-text="card.title || '(제목 없음)'"></span>
|
||
</div>
|
||
<div class="li-memo" x-text="card.memo || card.suggestion || card.effect || ''"></div>
|
||
</div>
|
||
<div class="li-tags">
|
||
<template x-for="tag in (card.tags || []).slice(0, 3)" :key="tag">
|
||
<span class="card-tag" x-text="tag"></span>
|
||
</template>
|
||
</div>
|
||
<div class="card-rating" x-text="getRatingStars(card.rating || 0)" style="flex-shrink: 0;"></div>
|
||
<div class="li-date" x-text="formatDate(card.createdAt)"></div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Status Bar -->
|
||
<div class="di-statusbar">
|
||
<span class="stat"><i class="ri-sticky-note-line"></i> 카드 <strong x-text="currentProject.cards?.length || 0"></strong>개</span>
|
||
<span class="stat"><i class="ri-price-tag-3-line"></i> 태그 <strong x-text="allTags.length"></strong>개</span>
|
||
<span class="stat"><i class="ri-time-line"></i> 마지막 저장 <span x-text="lastSaved || '-'"></span></span>
|
||
<div style="flex:1;"></div>
|
||
<span class="di-paste-hint" style="position: static; opacity: .6; background: transparent; color: var(--di-text-secondary); padding: 0;">
|
||
<kbd>Ctrl+V</kbd> 붙여넣기
|
||
</span>
|
||
</div>
|
||
|
||
<!-- ===== Card Edit/Create Modal ===== -->
|
||
<template x-if="showCardModal">
|
||
<div class="di-modal-overlay" @click.self="closeCardModal()">
|
||
<div class="di-modal">
|
||
<div class="di-modal-header">
|
||
<h3 x-text="editingCard.id ? '카드 편집' : '새 카드'"></h3>
|
||
<div style="display: flex; gap: 6px; align-items: center;">
|
||
<template x-if="editingCard.id">
|
||
<button class="di-btn sm" style="color: var(--di-red); border-color: #fecaca;" @click="deleteCard(editingCard.id)">
|
||
<i class="ri-delete-bin-line"></i> 삭제
|
||
</button>
|
||
</template>
|
||
<button class="di-btn ghost" @click="closeCardModal()"><i class="ri-close-line" style="font-size: 18px;"></i></button>
|
||
</div>
|
||
</div>
|
||
<div class="di-modal-body">
|
||
<!-- Card Type -->
|
||
<div class="di-field">
|
||
<label>카드 유형</label>
|
||
<div style="display: flex; gap: 6px;">
|
||
<template x-for="type in cardTypes" :key="type.code">
|
||
<button class="di-btn sm" :class="editingCard.type === type.code && 'primary'"
|
||
@click="editingCard.type = type.code">
|
||
<span x-text="type.icon + ' ' + type.label"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Title -->
|
||
<div class="di-field">
|
||
<label>제목</label>
|
||
<input type="text" x-model="editingCard.title" placeholder="인사이트 제목">
|
||
</div>
|
||
|
||
<!-- Image (Reference / Analysis / Pattern) -->
|
||
<template x-if="editingCard.type !== 'comparison'">
|
||
<div class="di-field">
|
||
<label>이미지</label>
|
||
<div class="di-drop-zone"
|
||
@click="$refs.fileInput.click()"
|
||
@drop.prevent="handleDrop($event)"
|
||
@dragover.prevent="$event.currentTarget.classList.add('dragover')"
|
||
@dragleave="$event.currentTarget.classList.remove('dragover')">
|
||
<template x-if="editingCard.image">
|
||
<img :src="editingCard.image">
|
||
</template>
|
||
<template x-if="!editingCard.image">
|
||
<div>
|
||
<i class="ri-image-add-line" style="font-size: 32px; color: #94a3b8; display: block; margin-bottom: 8px;"></i>
|
||
<span style="font-size: 13px; color: var(--di-text-secondary);">클릭 또는 드래그로 이미지 추가</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<input type="file" x-ref="fileInput" accept="image/*" style="display: none;" @change="handleFileSelect($event)">
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Before/After Images (Comparison) -->
|
||
<template x-if="editingCard.type === 'comparison'">
|
||
<div class="di-field">
|
||
<label>Before / After 이미지</label>
|
||
<div class="di-comparison">
|
||
<div class="comp-side comp-before"
|
||
@click="$refs.beforeInput.click()"
|
||
@drop.prevent="handleCompDrop($event, 'before')"
|
||
@dragover.prevent="$event.currentTarget.classList.add('dragover')"
|
||
@dragleave="$event.currentTarget.classList.remove('dragover')">
|
||
<span class="comp-label">Before</span>
|
||
<template x-if="editingCard.beforeImage">
|
||
<img :src="editingCard.beforeImage">
|
||
</template>
|
||
<template x-if="!editingCard.beforeImage">
|
||
<i class="ri-image-add-line" style="font-size: 24px; color: #94a3b8;"></i>
|
||
</template>
|
||
</div>
|
||
<div class="comp-side comp-after"
|
||
@click="$refs.afterInput.click()"
|
||
@drop.prevent="handleCompDrop($event, 'after')"
|
||
@dragover.prevent="$event.currentTarget.classList.add('dragover')"
|
||
@dragleave="$event.currentTarget.classList.remove('dragover')">
|
||
<span class="comp-label">After</span>
|
||
<template x-if="editingCard.afterImage">
|
||
<img :src="editingCard.afterImage">
|
||
</template>
|
||
<template x-if="!editingCard.afterImage">
|
||
<i class="ri-image-add-line" style="font-size: 24px; color: #94a3b8;"></i>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<input type="file" x-ref="beforeInput" accept="image/*" style="display: none;" @change="handleCompFileSelect($event, 'before')">
|
||
<input type="file" x-ref="afterInput" accept="image/*" style="display: none;" @change="handleCompFileSelect($event, 'after')">
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Memo (Reference) -->
|
||
<template x-if="editingCard.type === 'reference'">
|
||
<div>
|
||
<div class="di-field">
|
||
<label>인사이트 메모</label>
|
||
<textarea x-model="editingCard.memo" placeholder="이 화면/패턴이 왜 좋은가? (또는 나쁜가?)"></textarea>
|
||
</div>
|
||
<div class="di-field">
|
||
<label>출처</label>
|
||
<input type="text" x-model="editingCard.source" placeholder="URL, 앱 이름, 서비스명 등">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Analysis Fields -->
|
||
<template x-if="editingCard.type === 'analysis'">
|
||
<div>
|
||
<div class="di-field">
|
||
<label>CRAP 디자인 원칙 체크</label>
|
||
<div class="di-crap-grid">
|
||
<template x-for="p in designPrinciples" :key="p.key">
|
||
<div class="di-crap-item"
|
||
:class="getPrincipleStatus(p.key)"
|
||
@click="cyclePrinciple(p.key)">
|
||
<span x-text="p.icon" style="font-size: 16px;"></span>
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 600; font-size: 12px;" x-text="p.label"></div>
|
||
<div style="font-size: 10.5px; color: var(--di-text-secondary);" x-text="p.desc"></div>
|
||
</div>
|
||
<span x-text="getPrincipleIcon(p.key)" style="font-size: 14px;"></span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div class="di-field">
|
||
<label>개선 제안</label>
|
||
<textarea x-model="editingCard.suggestion" placeholder="어떻게 개선할 수 있는가?"></textarea>
|
||
</div>
|
||
<div class="di-field">
|
||
<label>심각도</label>
|
||
<div style="display: flex; gap: 6px;">
|
||
<button class="di-btn sm" :class="editingCard.severity === 'info' && 'primary'" @click="editingCard.severity = 'info'">ℹ️ 정보</button>
|
||
<button class="di-btn sm" :class="editingCard.severity === 'warning' && 'primary'" @click="editingCard.severity = 'warning'" style="--di-blue: #f59e0b;">⚠️ 경고</button>
|
||
<button class="di-btn sm" :class="editingCard.severity === 'critical' && 'primary'" @click="editingCard.severity = 'critical'" style="--di-blue: #ef4444;">🔴 심각</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Pattern Fields -->
|
||
<template x-if="editingCard.type === 'pattern'">
|
||
<div>
|
||
<div class="di-field">
|
||
<label>사용처</label>
|
||
<input type="text" x-model="editingCard.usedInText" placeholder="수주 목록, 거래처 목록 (콤마 구분)">
|
||
</div>
|
||
<div class="di-field">
|
||
<label>구성 요소</label>
|
||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||
<template x-for="(comp, ci) in (editingCard.components || [])" :key="ci">
|
||
<div style="display: flex; gap: 6px; align-items: center;">
|
||
<input type="checkbox" x-model="comp.required">
|
||
<input type="text" x-model="comp.name" style="flex: 1; padding: 5px 8px; border: 1px solid var(--di-border); border-radius: 6px; font-size: 12.5px;" placeholder="구성 요소명">
|
||
<button class="di-btn ghost sm" @click="editingCard.components.splice(ci, 1)"><i class="ri-close-line"></i></button>
|
||
</div>
|
||
</template>
|
||
<button class="di-btn sm" @click="editingCard.components = [...(editingCard.components || []), {name: '', required: true}]">
|
||
<i class="ri-add-line"></i> 구성 요소 추가
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="di-field">
|
||
<label>사용 가이드라인</label>
|
||
<textarea x-model="editingCard.guidelines" placeholder="이 패턴을 사용할 때 주의사항이나 가이드"></textarea>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Comparison Fields -->
|
||
<template x-if="editingCard.type === 'comparison'">
|
||
<div>
|
||
<div class="di-field">
|
||
<label>변경 포인트</label>
|
||
<textarea x-model="editingCard.changesText" placeholder="1. 탭 구조 → 섹션 접기/펼치기 변경 2. 좌우 2컬럼 → 단일 컬럼" style="min-height: 100px;"></textarea>
|
||
</div>
|
||
<div class="di-field">
|
||
<label>개선 효과</label>
|
||
<input type="text" x-model="editingCard.effect" placeholder="스크롤 40% 감소, 작업 완료 시간 단축">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Common Fields -->
|
||
<div class="di-field">
|
||
<label>카테고리</label>
|
||
<select x-model="editingCard.category">
|
||
<template x-for="cat in categories" :key="cat.code">
|
||
<option :value="cat.code" x-text="cat.icon + ' ' + cat.label"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="di-field">
|
||
<label>태그 (콤마 구분)</label>
|
||
<input type="text" x-model="editingCard.tagsText" placeholder="대시보드, 카드, 레이아웃"
|
||
@keydown.enter.prevent>
|
||
</div>
|
||
|
||
<div class="di-field">
|
||
<label>평점</label>
|
||
<div class="di-stars">
|
||
<template x-for="s in [1,2,3,4,5]" :key="s">
|
||
<span class="di-star" :class="s <= (editingCard.rating || 0) && 'filled'"
|
||
@click="editingCard.rating = editingCard.rating === s ? 0 : s"
|
||
x-text="s <= (editingCard.rating || 0) ? '\u2605' : '\u2606'"></span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="di-modal-footer">
|
||
<button class="di-btn" @click="closeCardModal()">취소</button>
|
||
<button class="di-btn primary" @click="saveCard()">
|
||
<i class="ri-check-line"></i> <span x-text="editingCard.id ? '수정' : '추가'"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ===== Projects Modal ===== -->
|
||
<template x-if="showProjectsModal">
|
||
<div class="di-modal-overlay" @click.self="showProjectsModal = false">
|
||
<div class="di-modal" style="width: 500px;">
|
||
<div class="di-modal-header">
|
||
<h3>프로젝트 관리</h3>
|
||
<button class="di-btn ghost" @click="showProjectsModal = false"><i class="ri-close-line" style="font-size: 18px;"></i></button>
|
||
</div>
|
||
<div class="di-modal-body">
|
||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||
<input type="text" x-model="newProjectTitle" placeholder="새 프로젝트 이름"
|
||
style="flex: 1; padding: 8px 12px; border: 1px solid var(--di-border); border-radius: 8px; font-size: 13px;"
|
||
@keydown.enter="createProject()">
|
||
<button class="di-btn primary" @click="createProject()"><i class="ri-add-line"></i> 생성</button>
|
||
</div>
|
||
<div style="display: flex; flex-direction: column; gap: 4px;">
|
||
<template x-for="proj in projects" :key="proj.id">
|
||
<div class="di-proj-item" :class="proj.id === currentProject.id && 'active'" @click="switchProject(proj.id)">
|
||
<i class="ri-folder-line" style="font-size: 16px; color: var(--di-blue);"></i>
|
||
<span class="proj-title" x-text="proj.title"></span>
|
||
<span class="proj-count" x-text="(proj.cards?.length || 0) + '개'"></span>
|
||
<span class="proj-date" x-text="formatDate(proj.createdAt)"></span>
|
||
<button class="di-btn ghost sm" @click.stop="deleteProject(proj.id)" x-show="projects.length > 1"
|
||
title="삭제"><i class="ri-delete-bin-line" style="color: var(--di-red);"></i></button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Toast -->
|
||
<template x-if="toastMsg">
|
||
<div class="di-toast" x-text="toastMsg" x-init="setTimeout(() => toastMsg = '', 2500)"></div>
|
||
</template>
|
||
</div>
|
||
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script>
|
||
function designInsight() {
|
||
return {
|
||
// State
|
||
projects: [],
|
||
currentProject: { id: '', title: '', cards: [], createdAt: '', updatedAt: '' },
|
||
viewMode: 'board', // board | gallery | list
|
||
categoryFilter: 'all', // card type filter (tabs)
|
||
screenFilter: 'all', // screen category filter (sidebar)
|
||
searchQuery: '',
|
||
selectedTags: [],
|
||
sortBy: 'newest',
|
||
sidebarOpen: true,
|
||
showCardModal: false,
|
||
showProjectsModal: false,
|
||
showExportMenu: false,
|
||
editingCard: {},
|
||
newProjectTitle: '',
|
||
toastMsg: '',
|
||
lastSaved: '',
|
||
undoStack: [],
|
||
redoStack: [],
|
||
|
||
// Constants
|
||
cardTypes: [
|
||
{ code: 'reference', label: '레퍼런스', icon: '📷' },
|
||
{ code: 'analysis', label: '분석', icon: '🔍' },
|
||
{ code: 'pattern', label: '패턴', icon: '📐' },
|
||
{ code: 'comparison', label: 'Before/After', icon: '🔄' },
|
||
],
|
||
categories: [
|
||
{ code: 'dashboard', label: '대시보드', icon: '📊', color: '#6366f1' },
|
||
{ code: 'list', label: '목록', icon: '📋', color: '#3b82f6' },
|
||
{ code: 'form', label: '상세/폼', icon: '📝', color: '#10b981' },
|
||
{ code: 'modal', label: '모달/팝업', icon: '💬', color: '#f59e0b' },
|
||
{ code: 'navigation', label: '네비게이션', icon: '🧭', color: '#8b5cf6' },
|
||
{ code: 'auth', label: '로그인', icon: '🔐', color: '#ec4899' },
|
||
{ code: 'report', label: '보고서', icon: '📄', color: '#0ea5e9' },
|
||
{ code: 'etc', label: '기타', icon: '📎', color: '#64748b' },
|
||
],
|
||
designPrinciples: [
|
||
{ key: 'contrast', label: '대비 (Contrast)', icon: '🔲', desc: '중요 요소가 시각적으로 구분' },
|
||
{ key: 'repetition', label: '반복 (Repetition)', icon: '🔁', desc: '일관된 스타일 반복 적용' },
|
||
{ key: 'alignment', label: '정렬 (Alignment)', icon: '📏', desc: '논리적 정렬' },
|
||
{ key: 'proximity', label: '근접성 (Proximity)', icon: '🧲', desc: '관련 요소 그룹핑' },
|
||
{ key: 'whitespace', label: '여백 (Whitespace)', icon: '⬜', desc: '적절한 여백 확보' },
|
||
{ key: 'hierarchy', label: '계층 (Hierarchy)', icon: '🔺', desc: '정보 우선순위 시각화' },
|
||
{ key: 'consistency', label: '일관성 (Consistency)', icon: '🔗', desc: '다른 화면과의 일관성' },
|
||
{ key: 'a11y', label: '접근성 (A11y)', icon: '♿', desc: '색상 대비, 폰트 크기' },
|
||
],
|
||
|
||
// Init
|
||
init() {
|
||
this.loadProjects();
|
||
if (this.projects.length === 0) {
|
||
this.createDefaultProject();
|
||
}
|
||
this.loadCurrentProject();
|
||
},
|
||
|
||
// ===== Projects =====
|
||
loadProjects() {
|
||
try {
|
||
const data = localStorage.getItem('di_projects');
|
||
this.projects = data ? JSON.parse(data) : [];
|
||
} catch { this.projects = []; }
|
||
},
|
||
|
||
saveProjects() {
|
||
localStorage.setItem('di_projects', JSON.stringify(this.projects));
|
||
},
|
||
|
||
createDefaultProject() {
|
||
const proj = {
|
||
id: 'diproj_' + Date.now(),
|
||
title: 'SAM ERP 디자인 연구',
|
||
description: '',
|
||
cards: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
this.projects.push(proj);
|
||
localStorage.setItem('di_current', proj.id);
|
||
this.saveProjects();
|
||
},
|
||
|
||
loadCurrentProject() {
|
||
const currentId = localStorage.getItem('di_current');
|
||
const proj = this.projects.find(p => p.id === currentId) || this.projects[0];
|
||
if (proj) {
|
||
this.currentProject = proj;
|
||
localStorage.setItem('di_current', proj.id);
|
||
}
|
||
},
|
||
|
||
createProject() {
|
||
if (!this.newProjectTitle.trim()) return;
|
||
const proj = {
|
||
id: 'diproj_' + Date.now(),
|
||
title: this.newProjectTitle.trim(),
|
||
description: '',
|
||
cards: [],
|
||
createdAt: new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
this.projects.push(proj);
|
||
this.saveProjects();
|
||
this.switchProject(proj.id);
|
||
this.newProjectTitle = '';
|
||
this.toast('프로젝트 생성됨');
|
||
},
|
||
|
||
switchProject(id) {
|
||
this.saveProject();
|
||
const proj = this.projects.find(p => p.id === id);
|
||
if (proj) {
|
||
this.currentProject = proj;
|
||
localStorage.setItem('di_current', id);
|
||
this.clearFilters();
|
||
this.showProjectsModal = false;
|
||
}
|
||
},
|
||
|
||
deleteProject(id) {
|
||
if (this.projects.length <= 1) return;
|
||
if (!confirm('프로젝트를 삭제하시겠습니까?')) return;
|
||
this.projects = this.projects.filter(p => p.id !== id);
|
||
if (this.currentProject.id === id) {
|
||
this.currentProject = this.projects[0];
|
||
localStorage.setItem('di_current', this.currentProject.id);
|
||
}
|
||
this.saveProjects();
|
||
this.toast('프로젝트 삭제됨');
|
||
},
|
||
|
||
saveProject() {
|
||
this.currentProject.updatedAt = new Date().toISOString();
|
||
const idx = this.projects.findIndex(p => p.id === this.currentProject.id);
|
||
if (idx >= 0) this.projects[idx] = { ...this.currentProject };
|
||
this.saveProjects();
|
||
this.lastSaved = this.formatTime(new Date());
|
||
},
|
||
|
||
// ===== Cards =====
|
||
openNewCardModal(type) {
|
||
this.editingCard = {
|
||
id: '',
|
||
type: type || 'reference',
|
||
title: '',
|
||
image: '',
|
||
memo: '',
|
||
source: '',
|
||
tags: [],
|
||
tagsText: '',
|
||
category: 'etc',
|
||
rating: 0,
|
||
pinned: false,
|
||
archived: false,
|
||
// Analysis
|
||
principles: {},
|
||
suggestion: '',
|
||
severity: 'info',
|
||
// Pattern
|
||
usedIn: [],
|
||
usedInText: '',
|
||
components: [{ name: '', required: true }],
|
||
guidelines: '',
|
||
frequency: 0,
|
||
// Comparison
|
||
beforeImage: '',
|
||
afterImage: '',
|
||
changes: [],
|
||
changesText: '',
|
||
effect: '',
|
||
};
|
||
this.showCardModal = true;
|
||
},
|
||
|
||
openEditCardModal(card) {
|
||
this.editingCard = {
|
||
...card,
|
||
tagsText: (card.tags || []).join(', '),
|
||
usedInText: (card.usedIn || []).join(', '),
|
||
changesText: (card.changes || []).join('\n'),
|
||
components: card.components ? [...card.components.map(c => ({...c}))] : [{ name: '', required: true }],
|
||
principles: card.principles ? { ...card.principles } : {},
|
||
};
|
||
this.showCardModal = true;
|
||
},
|
||
|
||
closeCardModal() {
|
||
this.showCardModal = false;
|
||
this.editingCard = {};
|
||
},
|
||
|
||
saveCard() {
|
||
const card = this.editingCard;
|
||
|
||
// Parse text fields
|
||
card.tags = card.tagsText ? card.tagsText.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||
card.usedIn = card.usedInText ? card.usedInText.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||
card.changes = card.changesText ? card.changesText.split('\n').map(t => t.trim()).filter(Boolean) : [];
|
||
|
||
// Clean temp fields
|
||
const { tagsText, usedInText, changesText, ...cleanCard } = card;
|
||
|
||
// Remove empty components
|
||
if (cleanCard.components) {
|
||
cleanCard.components = cleanCard.components.filter(c => c.name.trim());
|
||
}
|
||
|
||
if (!cleanCard.id) {
|
||
// New card
|
||
cleanCard.id = 'di_' + Date.now() + '_' + Math.random().toString(36).substr(2, 4);
|
||
cleanCard.createdAt = new Date().toISOString();
|
||
cleanCard.updatedAt = new Date().toISOString();
|
||
if (!this.currentProject.cards) this.currentProject.cards = [];
|
||
this.currentProject.cards.unshift(cleanCard);
|
||
this.toast('카드 추가됨');
|
||
} else {
|
||
// Update card
|
||
cleanCard.updatedAt = new Date().toISOString();
|
||
const idx = this.currentProject.cards.findIndex(c => c.id === cleanCard.id);
|
||
if (idx >= 0) this.currentProject.cards[idx] = cleanCard;
|
||
this.toast('카드 수정됨');
|
||
}
|
||
|
||
this.saveProject();
|
||
this.closeCardModal();
|
||
},
|
||
|
||
deleteCard(id) {
|
||
if (!confirm('이 카드를 삭제하시겠습니까?')) return;
|
||
this.currentProject.cards = this.currentProject.cards.filter(c => c.id !== id);
|
||
this.saveProject();
|
||
this.closeCardModal();
|
||
this.toast('카드 삭제됨');
|
||
},
|
||
|
||
togglePin(card) {
|
||
card.pinned = !card.pinned;
|
||
this.saveProject();
|
||
},
|
||
|
||
// ===== Images =====
|
||
handlePaste(e) {
|
||
if (this.showCardModal || this.showProjectsModal) return;
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
e.preventDefault();
|
||
const file = item.getAsFile();
|
||
this.readImageFile(file, (dataUrl) => {
|
||
this.openNewCardModal('reference');
|
||
this.$nextTick(() => { this.editingCard.image = dataUrl; });
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
|
||
handleDrop(e) {
|
||
e.currentTarget.classList.remove('dragover');
|
||
const file = e.dataTransfer?.files?.[0];
|
||
if (file && file.type.startsWith('image/')) {
|
||
this.readImageFile(file, (dataUrl) => { this.editingCard.image = dataUrl; });
|
||
}
|
||
},
|
||
|
||
handleFileSelect(e) {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
this.readImageFile(file, (dataUrl) => { this.editingCard.image = dataUrl; });
|
||
}
|
||
e.target.value = '';
|
||
},
|
||
|
||
handleCompDrop(e, side) {
|
||
e.currentTarget.classList.remove('dragover');
|
||
const file = e.dataTransfer?.files?.[0];
|
||
if (file && file.type.startsWith('image/')) {
|
||
const key = side === 'before' ? 'beforeImage' : 'afterImage';
|
||
this.readImageFile(file, (dataUrl) => { this.editingCard[key] = dataUrl; });
|
||
}
|
||
},
|
||
|
||
handleCompFileSelect(e, side) {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
const key = side === 'before' ? 'beforeImage' : 'afterImage';
|
||
this.readImageFile(file, (dataUrl) => { this.editingCard[key] = dataUrl; });
|
||
}
|
||
e.target.value = '';
|
||
},
|
||
|
||
readImageFile(file, callback) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => callback(e.target.result);
|
||
reader.readAsDataURL(file);
|
||
},
|
||
|
||
// ===== CRAP Principles =====
|
||
cyclePrinciple(key) {
|
||
if (!this.editingCard.principles) this.editingCard.principles = {};
|
||
const current = this.editingCard.principles[key] || '';
|
||
const cycle = { '': 'pass', 'pass': 'warn', 'warn': 'fail', 'fail': '' };
|
||
this.editingCard.principles[key] = cycle[current] || '';
|
||
},
|
||
|
||
getPrincipleStatus(key) {
|
||
return (this.editingCard.principles || {})[key] || '';
|
||
},
|
||
|
||
getPrincipleIcon(key) {
|
||
const status = this.getPrincipleStatus(key);
|
||
return { pass: '✅', warn: '⚠️', fail: '❌', '': '—' }[status] || '—';
|
||
},
|
||
|
||
// ===== Filters =====
|
||
get filteredCards() {
|
||
let cards = this.currentProject.cards || [];
|
||
|
||
// Card type filter (tabs)
|
||
if (this.categoryFilter !== 'all') {
|
||
cards = cards.filter(c => c.type === this.categoryFilter);
|
||
}
|
||
|
||
// Screen category filter (sidebar)
|
||
if (this.screenFilter !== 'all') {
|
||
cards = cards.filter(c => c.category === this.screenFilter);
|
||
}
|
||
|
||
// Tag filter
|
||
if (this.selectedTags.length > 0) {
|
||
cards = cards.filter(c => {
|
||
const cardTags = c.tags || [];
|
||
return this.selectedTags.some(t => cardTags.includes(t));
|
||
});
|
||
}
|
||
|
||
// Search
|
||
if (this.searchQuery.trim()) {
|
||
const q = this.searchQuery.toLowerCase();
|
||
cards = cards.filter(c =>
|
||
(c.title || '').toLowerCase().includes(q) ||
|
||
(c.memo || '').toLowerCase().includes(q) ||
|
||
(c.suggestion || '').toLowerCase().includes(q) ||
|
||
(c.effect || '').toLowerCase().includes(q) ||
|
||
(c.source || '').toLowerCase().includes(q) ||
|
||
(c.tags || []).some(t => t.toLowerCase().includes(q))
|
||
);
|
||
}
|
||
|
||
// Sort — pinned first
|
||
cards = [...cards].sort((a, b) => {
|
||
if (a.pinned && !b.pinned) return -1;
|
||
if (!a.pinned && b.pinned) return 1;
|
||
return 0;
|
||
});
|
||
|
||
// Sort by
|
||
cards.sort((a, b) => {
|
||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||
switch (this.sortBy) {
|
||
case 'newest': return new Date(b.createdAt) - new Date(a.createdAt);
|
||
case 'oldest': return new Date(a.createdAt) - new Date(b.createdAt);
|
||
case 'rating': return (b.rating || 0) - (a.rating || 0);
|
||
case 'title': return (a.title || '').localeCompare(b.title || '');
|
||
default: return 0;
|
||
}
|
||
});
|
||
|
||
return cards;
|
||
},
|
||
|
||
get allTags() {
|
||
const tags = new Set();
|
||
(this.currentProject.cards || []).forEach(c => {
|
||
(c.tags || []).forEach(t => tags.add(t));
|
||
});
|
||
return [...tags].sort();
|
||
},
|
||
|
||
toggleTag(tag) {
|
||
const idx = this.selectedTags.indexOf(tag);
|
||
if (idx >= 0) this.selectedTags.splice(idx, 1);
|
||
else this.selectedTags.push(tag);
|
||
},
|
||
|
||
clearFilters() {
|
||
this.categoryFilter = 'all';
|
||
this.screenFilter = 'all';
|
||
this.searchQuery = '';
|
||
this.selectedTags = [];
|
||
this.sortBy = 'newest';
|
||
},
|
||
|
||
getCardCountByType(type) {
|
||
return (this.currentProject.cards || []).filter(c => c.type === type).length;
|
||
},
|
||
|
||
getCardCountByCat(cat) {
|
||
return (this.currentProject.cards || []).filter(c => c.category === cat).length;
|
||
},
|
||
|
||
// ===== Export / Import =====
|
||
exportJSON() {
|
||
const data = JSON.stringify(this.currentProject, null, 2);
|
||
const blob = new Blob([data], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (this.currentProject.title || 'design-insight') + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
this.toast('JSON 내보내기 완료');
|
||
},
|
||
|
||
importJSON() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.json';
|
||
input.onchange = (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {
|
||
try {
|
||
const data = JSON.parse(ev.target.result);
|
||
if (data.id && data.cards) {
|
||
// Check if project already exists
|
||
const existIdx = this.projects.findIndex(p => p.id === data.id);
|
||
if (existIdx >= 0) {
|
||
if (confirm('동일 ID 프로젝트가 있습니다. 덮어쓰시겠습니까?')) {
|
||
this.projects[existIdx] = data;
|
||
} else return;
|
||
} else {
|
||
this.projects.push(data);
|
||
}
|
||
this.saveProjects();
|
||
this.switchProject(data.id);
|
||
this.toast('프로젝트 가져오기 완료');
|
||
}
|
||
} catch { this.toast('JSON 파일 오류'); }
|
||
};
|
||
reader.readAsText(file);
|
||
};
|
||
input.click();
|
||
},
|
||
|
||
// ===== Keyboard =====
|
||
handleKeydown(e) {
|
||
if (this.showCardModal || this.showProjectsModal) return;
|
||
|
||
// Ctrl+S — Save
|
||
if (e.ctrlKey && e.key === 's') {
|
||
e.preventDefault();
|
||
this.saveProject();
|
||
this.toast('저장됨');
|
||
}
|
||
// Ctrl+N — New card
|
||
if (e.ctrlKey && e.key === 'n') {
|
||
e.preventDefault();
|
||
this.openNewCardModal('reference');
|
||
}
|
||
// Ctrl+F — Focus search
|
||
if (e.ctrlKey && e.key === 'f') {
|
||
e.preventDefault();
|
||
document.querySelector('.di-search-input')?.focus();
|
||
}
|
||
},
|
||
|
||
// ===== Helpers =====
|
||
getTypeLabel(type) {
|
||
const t = this.cardTypes.find(ct => ct.code === type);
|
||
return t ? t.icon + ' ' + t.label : type;
|
||
},
|
||
|
||
getTypeIcon(type) {
|
||
const map = {
|
||
reference: 'ri-camera-line',
|
||
analysis: 'ri-search-eye-line',
|
||
pattern: 'ri-layout-masonry-line',
|
||
comparison: 'ri-arrow-left-right-line',
|
||
};
|
||
return map[type] || 'ri-file-line';
|
||
},
|
||
|
||
getRatingStars(rating) {
|
||
return '\u2605'.repeat(rating) + '\u2606'.repeat(5 - rating);
|
||
},
|
||
|
||
formatDate(dateStr) {
|
||
if (!dateStr) return '';
|
||
const d = new Date(dateStr);
|
||
return d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0');
|
||
},
|
||
|
||
formatTime(d) {
|
||
return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0') + ':' + String(d.getSeconds()).padStart(2,'0');
|
||
},
|
||
|
||
toast(msg) {
|
||
this.toastMsg = msg;
|
||
setTimeout(() => { this.toastMsg = ''; }, 2500);
|
||
},
|
||
};
|
||
}
|
||
</script>
|
||
@endpush
|