Files
sam-manage/resources/views/rd/design-insight/index.blade.php
김보곤 5898a29077 feat: [rd] 디자인 인사이트 메뉴 Phase 1 MVP 구현
- GET /rd/design-insight 라우트 + 컨트롤러 추가
- Alpine.js 단일 파일 SPA (localStorage 기반)
- 4종 카드: 레퍼런스, 분석(CRAP), 패턴, Before/After
- 3종 뷰: 보드, 갤러리, 리스트
- Ctrl+V 클립보드 이미지 붙여넣기
- 프로젝트 CRUD, 태그/카테고리 필터, 검색
- JSON 내보내기/가져오기
2026-03-08 09:56:01 +09:00

1700 lines
68 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@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 ? '&#x1F4CC;' : '&#x1F4CC;'"></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. 탭 구조 → 섹션 접기/펼치기 변경&#10;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