feat(ui): Redesign sidebar, one-touch toggles, fix trash & subtask optimistic UI, auto-admin, and full n-depth DND
Build and Push Docker Image / build-and-push (push) Successful in 10m30s
Build and Push Docker Image / build-and-push (push) Successful in 10m30s
This commit is contained in:
@@ -30,6 +30,12 @@
|
|||||||
- Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage.
|
- Automated Gitea Actions CI workflow (`.gitea/workflows/docker-build.yaml`) for building & pushing container images to Gitea Container Registry with lowercase repository name normalization (`env.REPO`) and OCI source labels (`org.opencontainers.image.source`) for automatic repository package linkage.
|
||||||
- Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines.
|
- Completely revamped `README.md` to reflect universal 2-file Docker deployment for general users, Watchtower auto-update, architecture, and open-standard CalDAV sync guidelines.
|
||||||
- Enhanced Task Row UX & Interactive Undo Toast (v0.6.3): Notion-style 6-dot drag handle (`⋮⋮`), DND reordering, `F2` inline editing, right-aligned subtask button, and 6-second floating Undo toast banner.
|
- Enhanced Task Row UX & Interactive Undo Toast (v0.6.3): Notion-style 6-dot drag handle (`⋮⋮`), DND reordering, `F2` inline editing, right-aligned subtask button, and 6-second floating Undo toast banner.
|
||||||
|
- Completely redesigned Left Sidebar UI/UX (v0.7.0): Brand checkmark SVG logo, centered matte search box (Ctrl+K), bottom-docked user card, fixed list redirect bug on trash selection, and synchronized live trash counter.
|
||||||
|
- Minimal & Matte Kanban View Switcher (v0.7.0): Compact single toggle visible only when Kanban is enabled in Labs, with seamless real-time switching.
|
||||||
|
- Markdown Note Editor defaulting to Preview mode with one-touch toggle button (v0.7.0).
|
||||||
|
- Real-time Optimistic UI updates for subtask inline addition & checklist checkbox clicks without requiring page refreshes (v0.7.0).
|
||||||
|
- Auto-promotion of the first-ever registered user to ADMIN role on self-hosted instances (v0.7.0).
|
||||||
|
- Full N-depth tree drag-and-drop (DND) reordering for main tasks and subtasks with LocalStorage persistence (v0.7.0).
|
||||||
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
- Validated that `npm run lint` and `npm run build` execute with 0 errors and 0 warnings.
|
||||||
|
|
||||||
- **Next Steps (Todo):**
|
- **Next Steps (Todo):**
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ src/
|
|||||||
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
|
| `checkpoint-v0.6.1` | CalDAV / DAVx⁵ 양방향 동기화 프로토콜 고도화(OPTIONS, PROPFIND, REPORT, PUT, DELETE), 설정 모달 내 플랫폼별(Android DAVx⁵ / Apple Reminders / Thunderbird) 인터랙티브 가이드 및 실시간 엔드포인트 테스트 핑, 다국어 i18n 동기화 | 2026-08-21 |
|
||||||
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
| `checkpoint-v0.6.2` | 표준 태스크 내보내기(Export) 기능 구현: TickTick/RFC 4180 호환 CSV (UTF-8 BOM 포함) 및 RFC 5545 iCalendar (`.ics` VTODO), 사이드바 프로필 메뉴 연동, 데모 모드 로컬 Blob 내보내기 지원, 다국어 사전 동기화 | 2026-08-21 |
|
||||||
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
| `checkpoint-v0.6.3` | 노션 스타일 6도트 드래그 핸들(`⋮⋮`) & DND 태스크 재정렬, F2 단축키 인라인 제목 수정, 우측 호버 서브태스크 추가 버튼, 6초 플로팅 인터랙티브 실행 취소(Undo) 토스트 배너 탑재, 휴지통 및 로컬 스토리지 트리 중복 버그 수정 | 2026-08-21 |
|
||||||
| `checkpoint-v0.6.4` | **(현재 최신)** i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
| `checkpoint-v0.6.4` | i18n 모듈러 사전 아키텍처(`types.ts`, `locales/en.ts`, `locales/ko.ts`, `locales/ja.ts`) 분리 및 번역 완성도 100%, 어드민 콘솔(`/admin`) 데모 프리뷰 및 멀티유저 게이트 연동, 설정 모달 Admin 탭 정보 고도화, 사이드바 프로필 메뉴 내 Admin Console 진입점 추가 | 2026-08-21 |
|
||||||
|
| `checkpoint-v0.7.0` | **(현재 최신)** 좌측 사이드바 UI/UX 전면 리디자인(브랜드 로고 SVG, 중앙정렬 매트 검색창, 바닥 밀착 프로필), 뷰 전환(리스트/칸반) 미니멀 원터치 토글 및 실험실 연동 버그 수정, 휴지통 카운트 실시간 동기화 & 기본화면 튕김 버그 수정, 첫 가입 계정 자동 ADMIN 승격, 서브태스크 인라인 추가 및 체크박스 Optimistic UI 실시간 렌더링, 메모장 기본 Preview 모드 & 원터치 토글, N-depth 계층형 DND 드래그 앤 드롭 재정렬 지원 | 2026-08-21 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+28
-2
@@ -459,9 +459,30 @@ a { color: inherit; text-decoration: none; }
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w, var(--sidebar-width));
|
||||||
|
min-width: var(--sidebar-w, var(--sidebar-width));
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width var(--dur-normal) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-box:hover {
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
background: var(--bg-primary) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-footer {
|
.sidebar-footer {
|
||||||
|
margin-top: auto;
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
@@ -471,10 +492,15 @@ a { color: inherit; text-decoration: none; }
|
|||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--dur-fast);
|
transition: all 0.15s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-card:hover { background: var(--bg-hover); }
|
.user-card:hover {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--border);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
|
|||||||
@@ -483,6 +483,7 @@ export function AppShell({ user, isDemo = false }: AppShellProps) {
|
|||||||
onTagSelect={handleTagSelect}
|
onTagSelect={handleTagSelect}
|
||||||
isTrashActive={isTrashActive}
|
isTrashActive={isTrashActive}
|
||||||
onTrashSelect={handleTrashSelect}
|
onTrashSelect={handleTrashSelect}
|
||||||
|
trashCount={isDemo && typeof window !== "undefined" ? getAllTrashTasks(getDemoStore().tasks).length : undefined}
|
||||||
mobileOpen={sidebarOpen}
|
mobileOpen={sidebarOpen}
|
||||||
onClose={() => setSidebarOpen(false)}
|
onClose={() => setSidebarOpen(false)}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface SidebarProps {
|
|||||||
onTagSelect: (tag: string | null) => void;
|
onTagSelect: (tag: string | null) => void;
|
||||||
isTrashActive: boolean;
|
isTrashActive: boolean;
|
||||||
onTrashSelect: () => void;
|
onTrashSelect: () => void;
|
||||||
|
trashCount?: number;
|
||||||
mobileOpen: boolean;
|
mobileOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isDemo?: boolean;
|
isDemo?: boolean;
|
||||||
@@ -48,6 +49,7 @@ export function Sidebar({
|
|||||||
onTagSelect,
|
onTagSelect,
|
||||||
isTrashActive,
|
isTrashActive,
|
||||||
onTrashSelect,
|
onTrashSelect,
|
||||||
|
trashCount: externalTrashCount,
|
||||||
mobileOpen,
|
mobileOpen,
|
||||||
onClose: _onClose,
|
onClose: _onClose,
|
||||||
isDemo = false,
|
isDemo = false,
|
||||||
@@ -73,7 +75,7 @@ export function Sidebar({
|
|||||||
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
const [exportIncludeCompleted, setExportIncludeCompleted] = useState<boolean>(true);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
const [tags, setTags] = useState<MockTag[]>(() => (typeof window !== "undefined" ? getCustomTags() : []));
|
||||||
const [trashCount, setTrashCount] = useState(() => {
|
const [internalTrashCount, setInternalTrashCount] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
return getAllTrashTasks(store.tasks).length;
|
return getAllTrashTasks(store.tasks).length;
|
||||||
@@ -81,6 +83,8 @@ export function Sidebar({
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trashCount = externalTrashCount !== undefined ? externalTrashCount : internalTrashCount;
|
||||||
|
|
||||||
// Context Menu for Lists
|
// Context Menu for Lists
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; listId: string } | null>(null);
|
||||||
|
|
||||||
@@ -91,11 +95,6 @@ export function Sidebar({
|
|||||||
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
const [renamingListId, setRenamingListId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
// Search expansion
|
|
||||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -103,9 +102,11 @@ export function Sidebar({
|
|||||||
setTags(getCustomTags());
|
setTags(getCustomTags());
|
||||||
const store = getDemoStore();
|
const store = getDemoStore();
|
||||||
const trashed = getAllTrashTasks(store.tasks);
|
const trashed = getAllTrashTasks(store.tasks);
|
||||||
setTrashCount(trashed.length);
|
setInternalTrashCount(trashed.length);
|
||||||
}, [lists]);
|
}, [lists]);
|
||||||
|
|
||||||
|
const initialListSelectedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isDemo) {
|
if (!isDemo) {
|
||||||
fetch("/api/lists")
|
fetch("/api/lists")
|
||||||
@@ -113,23 +114,20 @@ export function Sidebar({
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setLists(data);
|
setLists(data);
|
||||||
if (!selectedListId && data.length > 0) onListSelect(data[0].id);
|
if (!initialListSelectedRef.current && !selectedListId && !isTrashActive && !selectedTag && data.length > 0) {
|
||||||
|
initialListSelectedRef.current = true;
|
||||||
|
onListSelect(data[0].id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to load lists", err));
|
.catch((err) => console.error("Failed to load lists", err));
|
||||||
}
|
}
|
||||||
}, [isDemo, onListSelect, selectedListId, setLists]);
|
}, [isDemo, onListSelect, selectedListId, isTrashActive, selectedTag, setLists]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
if (showNewList) setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
}, [showNewList]);
|
}, [showNewList]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchExpanded) {
|
|
||||||
setTimeout(() => searchInputRef.current?.focus(), 60);
|
|
||||||
}
|
|
||||||
}, [searchExpanded]);
|
|
||||||
|
|
||||||
// Dismiss undo toast on unmount
|
// Dismiss undo toast on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -422,91 +420,41 @@ export function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
<aside className={`sidebar${mobileOpen ? " mobile-open" : ""}`}>
|
||||||
{/* Header with Logo & Controls */}
|
{/* Brand Header with Logo & Controls */}
|
||||||
<div className="sidebar-header" style={{ padding: "12px 12px 8px", gap: 6 }}>
|
<div className="sidebar-header" style={{ padding: "14px 14px 10px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
||||||
<div className="sidebar-logo">✓</div>
|
<div style={{ display: "flex", alignItems: "center", gap: 9, minWidth: 0 }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
|
<div
|
||||||
<span className="sidebar-title" style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
className="sidebar-brand-logo"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
background: "linear-gradient(135deg, var(--accent) 0%, #3a62d0 100%)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
boxShadow: "0 2px 6px rgba(75, 123, 245, 0.3)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ffffff" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
|
<span className="sidebar-title" style={{ fontSize: 15, fontWeight: 700, letterSpacing: "-0.3px", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
{t("appName")}
|
{t("appName")}
|
||||||
</span>
|
</span>
|
||||||
{isDemo && (
|
{isDemo && (
|
||||||
<span style={{ fontSize: 10, color: "var(--accent)", fontWeight: 700, letterSpacing: 0.3 }}>
|
<span style={{ fontSize: 9.5, color: "var(--accent)", fontWeight: 700, letterSpacing: "0.4px", marginTop: -2 }}>
|
||||||
{t("demoBadge")}
|
{t("demoBadge")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Controls: Hover-expand Search, Language & Theme */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 2, flexShrink: 0 }}>
|
|
||||||
{/* Hover-expand Search */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
transition: "all var(--dur-normal) var(--ease-out)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setSearchExpanded(true)}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!searchQuery) setSearchExpanded(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{searchExpanded ? (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
background: "var(--bg-primary)",
|
|
||||||
border: "1.5px solid var(--accent)",
|
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
padding: "3px 8px",
|
|
||||||
animation: "expandSearch var(--dur-normal) var(--ease-out)",
|
|
||||||
width: 180,
|
|
||||||
boxShadow: "0 0 0 3px var(--accent-light)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="2.5">
|
|
||||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === "Escape") {
|
|
||||||
document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"));
|
|
||||||
setSearchExpanded(false);
|
|
||||||
setSearchQuery("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t("searchPlaceholder")}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
outline: "none",
|
|
||||||
fontSize: 12,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="icon-btn"
|
|
||||||
id="quick-search-btn"
|
|
||||||
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
|
||||||
title={t("searchPlaceholder")}
|
|
||||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top Controls: Language & Theme with Clear Spacing */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 4, flexShrink: 0 }}>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
{/* Theme 3-Way Toggle Button */}
|
{/* Theme 3-Way Toggle Button */}
|
||||||
@@ -515,7 +463,7 @@ export function Sidebar({
|
|||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
title={`${t("theme")}: ${theme === "system" ? t("themeSystem") : theme === "light" ? t("themeLight") : t("themeDark")}`}
|
||||||
style={{ width: 28, height: 28, flexShrink: 0 }}
|
style={{ width: 28, height: 28, flexShrink: 0, borderRadius: "var(--radius-sm)" }}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{theme === "system" ? (
|
{theme === "system" ? (
|
||||||
@@ -539,6 +487,51 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Centered Minimal Search Bar (Between Header and Lists) */}
|
||||||
|
<div style={{ padding: "4px 12px 10px" }}>
|
||||||
|
<div
|
||||||
|
className="sidebar-search-box"
|
||||||
|
id="quick-search-btn"
|
||||||
|
onClick={() => document.dispatchEvent(new CustomEvent("checkflow:openCommandPalette"))}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "6px 10px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
title={`${t("searchPlaceholder")} (Ctrl+K)`}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, minWidth: 0 }}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.2">
|
||||||
|
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
<span style={{ fontSize: 12.5, color: "var(--text-tertiary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||||||
|
{t("searchPlaceholder")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<kbd
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "var(--text-tertiary)",
|
||||||
|
padding: "1px 5px",
|
||||||
|
borderRadius: "var(--radius-xs)",
|
||||||
|
lineHeight: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ctrl K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nav List */}
|
{/* Nav List */}
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{/* Lists Section */}
|
{/* Lists Section */}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ interface MarkdownNoteEditorProps {
|
|||||||
|
|
||||||
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEditorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [mode, setMode] = useState<"edit" | "preview">("edit");
|
// Default to preview mode as requested
|
||||||
|
const [mode, setMode] = useState<"edit" | "preview">("preview");
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -101,27 +102,34 @@ export function MarkdownNoteEditor({ value, onChange, onSave }: MarkdownNoteEdit
|
|||||||
{t("notes").toUpperCase()}
|
{t("notes").toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="view-switcher-group" style={{ padding: 2 }}>
|
{/* Minimal Matte One-touch Mode Toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-switcher-btn${mode === "edit" ? " active" : ""}`}
|
className="btn btn-ghost btn-sm"
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
style={{
|
||||||
|
fontSize: 11.5,
|
||||||
|
padding: "2px 8px",
|
||||||
|
height: 24,
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: mode === "edit" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||||
|
color: mode === "edit" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (mode === "preview") {
|
||||||
setMode("edit");
|
setMode("edit");
|
||||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||||
|
} else {
|
||||||
|
setMode("preview");
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
|
title={mode === "preview" ? "Switch to Edit" : "Switch to Preview"}
|
||||||
>
|
>
|
||||||
✏️ Edit
|
{mode === "preview" ? "✏️ Edit" : "👁️ Preview"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-switcher-btn${mode === "preview" ? " active" : ""}`}
|
|
||||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
|
||||||
onClick={() => setMode("preview")}
|
|
||||||
>
|
|
||||||
👁️ Preview
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor / Preview Area */}
|
{/* Editor / Preview Area */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { ContextMenu } from "@/components/ui/ContextMenu";
|
import { ContextMenu } from "@/components/ui/ContextMenu";
|
||||||
import { KanbanView } from "./KanbanView";
|
import { KanbanView } from "./KanbanView";
|
||||||
import { useUserPrefs } from "@/lib/useUserPrefs";
|
import { useUserPrefs } from "@/lib/useUserPrefs";
|
||||||
|
import { getDemoStore, saveDemoStore, MockTask } from "@/lib/mockData";
|
||||||
|
|
||||||
export interface Tag { id: string; name: string; color: string }
|
export interface Tag { id: string; name: string; color: string }
|
||||||
|
|
||||||
@@ -209,10 +210,11 @@ function RecursiveTaskItem({
|
|||||||
{!isTrashMode && (
|
{!isTrashMode && (
|
||||||
<div
|
<div
|
||||||
className="task-drag-handle"
|
className="task-drag-handle"
|
||||||
draggable
|
draggable="true"
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.dataTransfer.setData("text/plain", task.id);
|
e.dataTransfer.setData("text/plain", task.id);
|
||||||
|
e.dataTransfer.setData("application/task-id", task.id);
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
}}
|
}}
|
||||||
title="Drag to reorder"
|
title="Drag to reorder"
|
||||||
@@ -543,11 +545,12 @@ export function TaskList({
|
|||||||
|
|
||||||
const { prefs, updatePrefs } = useUserPrefs();
|
const { prefs, updatePrefs } = useUserPrefs();
|
||||||
// Only show kanban if the Labs flag is explicitly enabled
|
// Only show kanban if the Labs flag is explicitly enabled
|
||||||
const kanbanEnabled = prefs.labs?.kanbanBoard ?? false;
|
const kanbanEnabled = Boolean(prefs.labs?.kanbanBoard);
|
||||||
const viewMode = kanbanEnabled ? prefs.viewMode : "list";
|
const viewMode = kanbanEnabled ? (prefs.viewMode || "list") : "list";
|
||||||
|
|
||||||
const handleViewModeChange = (mode: "list" | "kanban") => {
|
const handleToggleViewMode = () => {
|
||||||
updatePrefs({ viewMode: mode });
|
const nextMode = viewMode === "kanban" ? "list" : "kanban";
|
||||||
|
updatePrefs({ viewMode: nextMode });
|
||||||
};
|
};
|
||||||
|
|
||||||
// TickTick-style Quick Add Preset states
|
// TickTick-style Quick Add Preset states
|
||||||
@@ -598,8 +601,35 @@ export function TaskList({
|
|||||||
return () => document.removeEventListener("checkflow:addTask", handler);
|
return () => document.removeEventListener("checkflow:addTask", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Recursive update helper for local tree optimistic state
|
||||||
|
const updateTaskCompletedInTree = (tree: Task[], targetId: string, isComp: boolean): Task[] => {
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === targetId) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
completed: isComp,
|
||||||
|
completedAt: isComp ? new Date().toISOString() : null,
|
||||||
|
children: node.children ? updateTaskCompletedInTree(node.children, targetId, isComp) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: updateTaskCompletedInTree(node.children, targetId, isComp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
async (id: string, completed: boolean) => {
|
async (id: string, completed: boolean) => {
|
||||||
|
// 1. Optimistic UI update immediately
|
||||||
|
setTasks((prev) => {
|
||||||
|
const updated = updateTaskCompletedInTree(prev, id, completed);
|
||||||
|
return showCompleted ? updated : updated.filter((t) => t.id !== id || !completed);
|
||||||
|
});
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
if (onDemoToggleTask) onDemoToggleTask(id, completed);
|
||||||
return;
|
return;
|
||||||
@@ -615,9 +645,11 @@ export function TaskList({
|
|||||||
onRefresh();
|
onRefresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[TaskList] handleToggle failed", err);
|
console.error("[TaskList] handleToggle failed", err);
|
||||||
|
// Rollback on failure
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isDemo, onDemoToggleTask, onRefresh]
|
[isDemo, onDemoToggleTask, onRefresh, showCompleted, setTasks, fetchTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddTask = async (e: React.FormEvent) => {
|
const handleAddTask = async (e: React.FormEvent) => {
|
||||||
@@ -656,8 +688,43 @@ export function TaskList({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const insertSubtaskInTree = (tree: Task[], pId: string, subtask: Task): Task[] => {
|
||||||
|
return tree.map((node) => {
|
||||||
|
if (node.id === pId) {
|
||||||
|
const currentChildren = node.children || [];
|
||||||
|
return { ...node, children: [...currentChildren, subtask] };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return { ...node, children: insertSubtaskInTree(node.children, pId, subtask) };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
const handleAddSubtaskInline = async (title: string, parentId: string) => {
|
||||||
if (!listId) return;
|
if (!listId) return;
|
||||||
|
|
||||||
|
const tempId = "temp-subtask-" + Date.now();
|
||||||
|
const tempSubtask: Task = {
|
||||||
|
id: tempId,
|
||||||
|
listId,
|
||||||
|
parentId,
|
||||||
|
title: title.trim(),
|
||||||
|
note: null,
|
||||||
|
completed: false,
|
||||||
|
completedAt: null,
|
||||||
|
dueDate: null,
|
||||||
|
priority: 0,
|
||||||
|
sortOrder: 999,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
children: [],
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optimistic insert
|
||||||
|
setTasks((prev) => insertSubtaskInTree(prev, parentId, tempSubtask));
|
||||||
|
|
||||||
if (isDemo) {
|
if (isDemo) {
|
||||||
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
if (onDemoAddTask) onDemoAddTask(title, listId, parentId);
|
||||||
return;
|
return;
|
||||||
@@ -670,10 +737,27 @@ export function TaskList({
|
|||||||
body: JSON.stringify({ title, listId, parentId }),
|
body: JSON.stringify({ title, listId, parentId }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
const createdTask = await res.json();
|
||||||
|
// Replace temp subtask with actual created task
|
||||||
|
setTasks((prev) => {
|
||||||
|
const replaceTemp = (nodes: Task[]): Task[] => {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
if (n.id === tempId) return createdTask;
|
||||||
|
if (n.children && n.children.length > 0) {
|
||||||
|
return { ...n, children: replaceTemp(n.children) };
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return replaceTemp(prev);
|
||||||
|
});
|
||||||
onRefresh();
|
onRefresh();
|
||||||
|
} else {
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add subtask inline", err);
|
console.error("Failed to add subtask inline", err);
|
||||||
|
fetchTasks();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -687,21 +771,69 @@ export function TaskList({
|
|||||||
setEditingHeader(false);
|
setEditingHeader(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tree-aware reordering function that supports reordering both top-level tasks and nested subtasks
|
||||||
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
|
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
const draggedIndex = prev.findIndex((t) => t.id === draggedId);
|
// Helper to find and remove a node from the tree
|
||||||
const targetIndex = prev.findIndex((t) => t.id === targetId);
|
let extractedNode: Task | null = null;
|
||||||
if (draggedIndex === -1 || targetIndex === -1) return prev;
|
|
||||||
|
|
||||||
const newArr = [...prev];
|
const removeNode = (nodes: Task[]): Task[] => {
|
||||||
const [draggedItem] = newArr.splice(draggedIndex, 1);
|
const result: Task[] = [];
|
||||||
const insertAt = position === "before"
|
for (const node of nodes) {
|
||||||
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex)
|
if (node.id === draggedId) {
|
||||||
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
|
extractedNode = node;
|
||||||
newArr.splice(Math.max(0, insertAt), 0, draggedItem);
|
} else {
|
||||||
return newArr;
|
const updatedNode = { ...node };
|
||||||
|
if (updatedNode.children && updatedNode.children.length > 0) {
|
||||||
|
updatedNode.children = removeNode(updatedNode.children);
|
||||||
|
}
|
||||||
|
result.push(updatedNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const treeWithoutDragged = removeNode(prev);
|
||||||
|
if (!extractedNode) return prev;
|
||||||
|
|
||||||
|
// Helper to insert the extracted node adjacent to targetId in the same list/level
|
||||||
|
const insertNode = (nodes: Task[]): { list: Task[]; inserted: boolean } => {
|
||||||
|
const idx = nodes.findIndex((n) => n.id === targetId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const newNodes = [...nodes];
|
||||||
|
const insertIdx = position === "before" ? idx : idx + 1;
|
||||||
|
// Maintain target's parentId for the dragged node
|
||||||
|
const adjustedNode = { ...extractedNode!, parentId: nodes[idx].parentId };
|
||||||
|
newNodes.splice(insertIdx, 0, adjustedNode);
|
||||||
|
return { list: newNodes, inserted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasInserted = false;
|
||||||
|
const newNodes = nodes.map((node) => {
|
||||||
|
if (!hasInserted && node.children && node.children.length > 0) {
|
||||||
|
const res = insertNode(node.children);
|
||||||
|
if (res.inserted) {
|
||||||
|
hasInserted = true;
|
||||||
|
return { ...node, children: res.list };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
});
|
});
|
||||||
}, [setTasks]);
|
|
||||||
|
return { list: newNodes, inserted: hasInserted };
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalTree = insertNode(treeWithoutDragged).list;
|
||||||
|
|
||||||
|
// Update demo store or backend sort orders
|
||||||
|
if (isDemo && typeof window !== "undefined") {
|
||||||
|
const store = getDemoStore();
|
||||||
|
saveDemoStore(store.lists, finalTree as unknown as MockTask[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalTree;
|
||||||
|
});
|
||||||
|
}, [isDemo, setTasks]);
|
||||||
|
|
||||||
const handleDeleteTask = useCallback((task: Task) => {
|
const handleDeleteTask = useCallback((task: Task) => {
|
||||||
if (onDeleteTaskWithUndo) {
|
if (onDeleteTaskWithUndo) {
|
||||||
@@ -793,26 +925,33 @@ export function TaskList({
|
|||||||
|
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div className="main-header-actions" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
{/* View Switcher: List vs Kanban (Hidden in Trash/Tag mode) */}
|
{/* View Switcher: Minimal & Matte Single Toggle (Only shown when Kanban is enabled in Labs) */}
|
||||||
{!isTrashActive && !selectedTag && (
|
{!isTrashActive && !selectedTag && kanbanEnabled && (
|
||||||
<div className="view-switcher-group">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`view-switcher-btn${viewMode === "list" ? " active" : ""}`}
|
className={`view-switcher-toggle-btn${viewMode === "kanban" ? " active" : ""}`}
|
||||||
onClick={() => handleViewModeChange("list")}
|
onClick={handleToggleViewMode}
|
||||||
title={t("listView")}
|
title={viewMode === "kanban" ? `${t("listView")} (Switch to List)` : `${t("kanbanView")} (Switch to Kanban)`}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 5,
|
||||||
|
padding: "4px 8px",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: viewMode === "kanban" ? "var(--accent-light)" : "var(--bg-secondary)",
|
||||||
|
color: viewMode === "kanban" ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all var(--dur-fast)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
📋 <span className="desktop-only">{t("listView")}</span>
|
<span>{viewMode === "kanban" ? "📊" : "📋"}</span>
|
||||||
|
<span style={{ fontSize: 11.5 }}>
|
||||||
|
{viewMode === "kanban" ? t("kanbanView") : t("listView")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`view-switcher-btn${viewMode === "kanban" ? " active" : ""}`}
|
|
||||||
onClick={() => handleViewModeChange("kanban")}
|
|
||||||
title={t("kanbanView")}
|
|
||||||
>
|
|
||||||
📊 <span className="desktop-only">{t("kanbanView")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isTrashActive ? (
|
{isTrashActive ? (
|
||||||
|
|||||||
+21
-3
@@ -25,10 +25,28 @@ export const authOptions: NextAuthOptions = {
|
|||||||
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
const valid = await bcrypt.compare(credentials.password, user.passwordHash);
|
||||||
if (!valid) return null;
|
if (!valid) return null;
|
||||||
|
|
||||||
// Admin identification logic
|
// Admin identification logic:
|
||||||
|
// 1. Explicit admin env match
|
||||||
|
// 2. Email format match (admin@... or ...@checkflow.local)
|
||||||
|
// 3. First-ever registered user in the database is automatically granted ADMIN
|
||||||
const adminEmail = process.env.ADMIN_EMAIL;
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
const isEmailAdmin = adminEmail && user.email === adminEmail;
|
const isEmailAdmin = adminEmail && user.email.toLowerCase() === adminEmail.toLowerCase();
|
||||||
const isAdmin = isEmailAdmin || user.email.endsWith("@checkflow.local") || user.email.startsWith("admin@");
|
const isPrefixAdmin = user.email.endsWith("@checkflow.local") || user.email.toLowerCase().startsWith("admin@");
|
||||||
|
|
||||||
|
let isFirstUser = false;
|
||||||
|
try {
|
||||||
|
const firstUser = await prisma.user.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (firstUser && firstUser.id === user.id) {
|
||||||
|
isFirstUser = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[auth] failed to check first user", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = isEmailAdmin || isPrefixAdmin || isFirstUser;
|
||||||
const role = isAdmin ? "ADMIN" : "USER";
|
const role = isAdmin ? "ADMIN" : "USER";
|
||||||
|
|
||||||
return { id: user.id, email: user.email, name: user.name, role };
|
return { id: user.id, email: user.email, name: user.name, role };
|
||||||
|
|||||||
Reference in New Issue
Block a user