first init

This commit is contained in:
Power BI Dev
2026-08-17 10:52:09 +07:00
commit 88c7c653c2
9281 changed files with 2156575 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react';
import { Navbar } from './components/Navbar.js';
import { HomePage } from './components/HomePage.js';
import { BoardView } from './components/BoardView.js';
import { applyTheme, getTheme } from './utils/theme.js';
import { applyRetroMode, getRetroMode } from './utils/mode.js';
import { useTranslation, Language } from './utils/i18n.js';
export function App() {
const { t } = useTranslation();
const [currentPath, setCurrentPath] = useState(window.location.pathname);
const [currentHash, setCurrentHash] = useState(window.location.hash);
const [, setLangState] = useState<Language>('id');
const [, setThemeState] = useState(getTheme());
const [, setRetroState] = useState<boolean>(getRetroMode());
useEffect(() => {
// Apply initial theme & retro mode classes
applyTheme(getTheme());
applyRetroMode(getRetroMode());
const handlePopState = () => {
setCurrentPath(window.location.pathname);
setCurrentHash(window.location.hash);
};
const handleHashChange = () => {
setCurrentHash(window.location.hash);
};
window.addEventListener('popstate', handlePopState);
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('popstate', handlePopState);
window.removeEventListener('hashchange', handleHashChange);
};
}, []);
// Parse `/b/:boardId`
const isBoardRoute = currentPath.startsWith('/b/');
const boardId = isBoardRoute ? currentPath.replace('/b/', '').split('/')[0] : null;
const navigateToHome = () => {
window.history.pushState({}, '', '/');
setCurrentPath('/');
setCurrentHash('');
};
const navigateToBoard = (id: string, fragment: string) => {
window.history.pushState({}, '', `/b/${id}${fragment}`);
setCurrentPath(`/b/${id}`);
setCurrentHash(fragment);
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-[#080c18] text-slate-900 dark:text-slate-100 flex flex-col selection:bg-indigo-500 selection:text-white transition-colors duration-200">
<Navbar
onHomeClick={navigateToHome}
onLanguageChange={(l) => setLangState(l)}
onThemeChange={(th) => setThemeState(th)}
onRetroModeChange={(r) => setRetroState(r)}
/>
<main className="flex-1">
{isBoardRoute && boardId ? (
<BoardView
key={`${boardId}_${currentHash}`}
boardId={boardId}
hash={currentHash}
onBackHome={navigateToHome}
/>
) : (
<HomePage onOpenBoard={navigateToBoard} />
)}
</main>
{/* Footer */}
<footer className="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950/80 py-4 px-4 text-center text-xs text-slate-500 dark:text-slate-400 transition-colors duration-200">
<p>🔗 {t('footerText')}</p>
</footer>
</div>
);
}
export default App;
@@ -0,0 +1,213 @@
import React, { useState, useEffect } from 'react';
import { History, X, MoveRight, PlusCircle, AlertOctagon, CheckCircle2, Trash2, User, Clock, RefreshCw } from 'lucide-react';
import { ActivityLog } from '../types/index.js';
import { fetchBoardActivity } from '../services/api.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface BoardActivityModalProps {
boardId: string;
boardKey: string;
onClose: () => void;
}
function formatTimeAgo(isoString: string): string {
try {
const diffMs = Date.now() - new Date(isoString).getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${Math.max(1, diffSec)} detik lalu`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin} menit lalu`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `${diffHours} jam lalu`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} hari lalu`;
} catch {
return isoString;
}
}
export const BoardActivityModal: React.FC<BoardActivityModalProps> = ({ boardId, boardKey, onClose }) => {
const { t } = useTranslation();
const [logs, setLogs] = useState<ActivityLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadLogs = async () => {
setLoading(true);
setError(null);
try {
const data = await fetchBoardActivity(boardId, boardKey, 60);
setLogs(data);
} catch (err: any) {
setError(err.message || 'Failed to load activity logs');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadLogs();
}, [boardId, boardKey]);
const getActionBadge = (log: ActivityLog) => {
switch (log.action) {
case 'MOVED':
return (
<span className="bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 shrink-0">
<MoveRight size={11} className="text-indigo-600 dark:text-indigo-400" />
<span>Pindah Kolom</span>
</span>
);
case 'CREATED':
return (
<span className="bg-emerald-50 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-500/30 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 shrink-0">
<PlusCircle size={11} className="text-emerald-600 dark:text-emerald-400" />
<span>Dibuat</span>
</span>
);
case 'BLOCKED':
return (
<span className="bg-rose-50 dark:bg-rose-500/15 text-rose-700 dark:text-rose-300 border border-rose-200 dark:border-rose-500/30 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 shrink-0">
<AlertOctagon size={11} className="text-rose-600 dark:text-rose-400" />
<span>Blocked</span>
</span>
);
case 'UNBLOCKED':
return (
<span className="bg-cyan-50 dark:bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border border-cyan-200 dark:border-cyan-500/30 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 shrink-0">
<CheckCircle2 size={11} className="text-cyan-600 dark:text-cyan-400" />
<span>Unblocked</span>
</span>
);
case 'DELETED':
return (
<span className="bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700 text-[10px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 shrink-0">
<Trash2 size={11} />
<span>Dihapus</span>
</span>
);
default:
return null;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-xl w-full p-4 sm:p-7 relative text-slate-900 dark:text-slate-100 max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-3.5 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 rounded-xl flex items-center justify-center text-indigo-600 dark:text-indigo-400 shrink-0">
<History size={18} />
</div>
<div>
<h2 className="text-sm sm:text-base font-bold text-slate-900 dark:text-white tracking-tight">
{t('activityLogTitle')}
</h2>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
Log pergerakan kartu & aktivitas real-time tersimpan di database
</p>
</div>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
soundFx.playClick();
loadLogs();
}}
className="p-1.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-md transition-colors"
title="Refresh logs"
>
<RefreshCw size={15} className={loading ? 'animate-spin text-indigo-500' : ''} />
</button>
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="p-1.5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 rounded-md transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Logs Timeline List */}
<div className="flex-1 overflow-y-auto py-3.5 space-y-2.5 pr-1">
{loading && logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center text-slate-400 text-xs gap-2">
<div className="w-6 h-6 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<span>Memuat riwayat database...</span>
</div>
) : error ? (
<div className="p-4 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-300 rounded-xl text-xs">
{error}
</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-slate-400 text-xs border border-dashed border-slate-200 dark:border-slate-800 rounded-xl">
{t('noActivityLogs')}
</div>
) : (
logs.map((log) => (
<div
key={log.id}
className="bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800/80 p-3 rounded-xl flex items-start justify-between gap-3 text-xs"
>
<div className="space-y-1 min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{getActionBadge(log)}
<span className="font-bold text-slate-900 dark:text-slate-100 truncate">
{log.ticketTitle || 'Tugas'}
</span>
</div>
{log.fromColumnTitle && log.toColumnTitle && log.action === 'MOVED' ? (
<p className="text-[11px] text-slate-600 dark:text-slate-300 flex items-center gap-1.5 flex-wrap">
<span className="px-1.5 py-0.5 bg-slate-200/80 dark:bg-slate-800 rounded font-medium">
{log.fromColumnTitle}
</span>
<MoveRight size={12} className="text-slate-400" />
<span className="px-1.5 py-0.5 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-800 dark:text-indigo-300 rounded font-semibold">
{log.toColumnTitle}
</span>
</p>
) : log.details ? (
<p className="text-[11px] text-slate-500 dark:text-slate-400">
{log.details}
</p>
) : null}
<div className="flex items-center gap-3 pt-0.5 text-[10px] text-slate-400 dark:text-slate-500">
<span className="flex items-center gap-1">
<User size={11} />
<span className="font-medium text-slate-600 dark:text-slate-400">{log.userName}</span>
</span>
<span className="flex items-center gap-1">
<Clock size={11} />
<span>{formatTimeAgo(log.createdAt)}</span>
</span>
</div>
</div>
</div>
))
)}
</div>
{/* Footer */}
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 flex justify-end">
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="btn-modern-primary py-2 px-5 text-xs font-bold"
>
{t('close')}
</button>
</div>
</div>
</div>
);
};
+982
View File
@@ -0,0 +1,982 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverEvent,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
Crown,
Shield,
Users,
Plus,
Share2,
RefreshCw,
Trash2,
ArrowLeft,
AlertCircle,
Sliders,
History,
} from 'lucide-react';
import { Board, Column, Ticket, Role, FieldLock, PresenceUser } from '../types/index.js';
import { ColumnContainer } from './ColumnContainer.js';
import { TicketCard } from './TicketCard.js';
import { TicketModal } from './TicketModal.js';
import { SuccessShareModal } from './SuccessShareModal.js';
import { RegenerateKeyModal } from './RegenerateKeyModal.js';
import { KeyPromptModal } from './KeyPromptModal.js';
import { BoardActivityModal } from './BoardActivityModal.js';
import { fetchBoard, deleteBoard } from '../services/api.js';
import { initSocket, getSocket, disconnectSocket } from '../services/socket.js';
import { getUserProfile, saveBoardToStorage, getSavedKeyForBoard } from '../utils/storage.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface BoardViewProps {
boardId: string;
hash?: string;
onBackHome: () => void;
}
function safeDecode(val: string): string {
try {
return decodeURIComponent(val);
} catch {
return val;
}
}
function extractKeyFromSources(boardId: string, propHash?: string): { key: string; role: Role } | null {
try {
// 1. Try propHash or window.location.hash
let hashStr = (propHash || window.location.hash || '').trim();
if (hashStr.startsWith('#')) hashStr = hashStr.substring(1);
if (hashStr.includes('admin=')) {
const k = hashStr.split('admin=')[1].split('&')[0];
if (k) return { key: safeDecode(k), role: 'admin' };
}
if (hashStr.includes('member=')) {
const k = hashStr.split('member=')[1].split('&')[0];
if (k) return { key: safeDecode(k), role: 'member' };
}
// Direct key without prefix in hash
if (hashStr && !hashStr.includes('=')) {
return { key: safeDecode(hashStr), role: 'member' };
}
// 2. Try window.location.search (?admin=... or ?member=...)
const urlParams = new URLSearchParams(window.location.search);
const queryAdmin = urlParams.get('admin');
const queryMember = urlParams.get('member');
if (queryAdmin) return { key: safeDecode(queryAdmin), role: 'admin' };
if (queryMember) return { key: queryMember, role: 'member' };
// 3. Try LocalStorage saved key
const saved = getSavedKeyForBoard(boardId);
if (saved && saved.key) {
return { key: saved.key, role: saved.role };
}
} catch (err) {
console.error('Error extracting key from sources:', err);
}
return null;
}
export const BoardView: React.FC<BoardViewProps> = ({ boardId, hash: propHash, onBackHome }) => {
const { t } = useTranslation();
const [key, setKey] = useState<string>('');
const [board, setBoard] = useState<Board | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<Role>('member');
// Concurrency & Presence
const [activeLocks, setActiveLocks] = useState<FieldLock[]>([]);
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([]);
const [profile, setProfile] = useState(getUserProfile());
// Active Drag State
const [activeDragItem, setActiveDragItem] = useState<{
type: 'Ticket' | 'Column';
data: Ticket | Column;
} | null>(null);
// Modals
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [showShareModal, setShowShareModal] = useState<boolean>(false);
const [showRotateKeyModal, setShowRotateKeyModal] = useState<boolean>(false);
const [showKeyPrompt, setShowKeyPrompt] = useState<boolean>(false);
const [showBoardSettings, setShowBoardSettings] = useState<boolean>(false);
const [showActivityModal, setShowActivityModal] = useState<boolean>(false);
const [isAddingColumn, setIsAddingColumn] = useState<boolean>(false);
const [newColTitle, setNewColTitle] = useState<string>('');
const [newColWip, setNewColWip] = useState<number>(0);
const [newColStale, setNewColStale] = useState<number>(0);
// Profile update listener
useEffect(() => {
const handleProfileUpdate = (e: Event) => {
const customEvent = e as CustomEvent<{ userId: string; userName: string }>;
if (customEvent.detail) {
setProfile(customEvent.detail);
}
};
window.addEventListener('user_profile_updated', handleProfileUpdate);
return () => window.removeEventListener('user_profile_updated', handleProfileUpdate);
}, []);
// Main Unified Board Loader
useEffect(() => {
let isMounted = true;
setLoading(true);
setError(null);
const auth = extractKeyFromSources(boardId, propHash);
if (!auth || !auth.key) {
setLoading(false);
setShowKeyPrompt(true);
return;
}
const activeKey = auth.key;
const activeRole = auth.role;
setKey(activeKey);
setRole(activeRole);
// Sync window hash
if (!window.location.hash || !window.location.hash.includes(activeKey)) {
window.location.hash = `${activeRole}=${activeKey}`;
}
// Safety timeout to prevent infinite stuck loading
const safetyTimeout = setTimeout(() => {
if (isMounted && loading) {
setLoading(false);
setError('Waktu koneksi habis (Connection timeout). Silakan periksa kembali tautan Anda.');
}
}, 10000);
fetchBoard(boardId, activeKey)
.then((data) => {
if (!isMounted) return;
clearTimeout(safetyTimeout);
setBoard(data);
setRole(data.role);
setLoading(false);
saveBoardToStorage({
id: data.id,
title: data.title,
role: data.role,
key: activeKey,
});
const socket = initSocket(boardId, activeKey, profile.userName, profile.userId);
socket.on('presence_update', (users: PresenceUser[]) => {
setPresenceUsers(users);
});
socket.on('active_locks', (locks: FieldLock[]) => {
setActiveLocks(locks);
});
socket.on('field_locked', (lock: FieldLock) => {
setActiveLocks((prev) => {
const filtered = prev.filter(
(l) => !(l.ticketId === lock.ticketId && l.field === lock.field)
);
return [...filtered, lock];
});
});
socket.on('field_unlocked', (payload: { ticketId: string; field: string; updatedTicket?: Ticket }) => {
setActiveLocks((prev) =>
prev.filter((l) => !(l.ticketId === payload.ticketId && l.field === payload.field))
);
if (payload.updatedTicket) {
handleTicketUpdatedLocally(payload.updatedTicket);
}
});
socket.on('ticket_created', (payload: { ticket: Ticket }) => {
setBoard((prev) => {
if (!prev) return prev;
return {
...prev,
columns: prev.columns.map((c) => {
if (c.id === payload.ticket.columnId) {
if (c.tickets.some((t) => t.id === payload.ticket.id)) return c;
return { ...c, tickets: [...c.tickets, payload.ticket] };
}
return c;
}),
};
});
soundFx.playDrop();
});
socket.on('ticket_updated', (payload: { ticket: Ticket }) => {
handleTicketUpdatedLocally(payload.ticket);
});
socket.on('ticket_deleted', (payload: { ticketId: string; columnId: string }) => {
setBoard((prev) => {
if (!prev) return prev;
return {
...prev,
columns: prev.columns.map((c) => ({
...c,
tickets: c.tickets.filter((t) => t.id !== payload.ticketId),
})),
};
});
if (selectedTicket && selectedTicket.id === payload.ticketId) {
setSelectedTicket(null);
}
});
socket.on('card_moved', (payload: { ticketId: string; targetColumnId: string; newOrder: number; ticketIdsInTargetColumn: string[] }) => {
setBoard((prev) => {
if (!prev) return prev;
let movedTicket: Ticket | null = null;
for (const col of prev.columns) {
const found = col.tickets.find((t) => t.id === payload.ticketId);
if (found) {
movedTicket = { ...found, columnId: payload.targetColumnId, order: payload.newOrder, movedAt: new Date().toISOString() };
break;
}
}
if (!movedTicket) return prev;
return {
...prev,
columns: prev.columns.map((col) => {
if (col.id === payload.targetColumnId) {
const filtered = col.tickets.filter((t) => t.id !== payload.ticketId);
const newTickets = [...filtered];
newTickets.splice(payload.newOrder, 0, movedTicket!);
return { ...col, tickets: newTickets };
} else {
return { ...col, tickets: col.tickets.filter((t) => t.id !== payload.ticketId) };
}
}),
};
});
soundFx.playDrop();
});
socket.on('card_move_failed', (payload: { ticketId: string; error: string }) => {
soundFx.playLock();
alert(`${t('movementFailed')} ${payload.error}`);
});
socket.on('column_created', (payload: { column: Column }) => {
setBoard((prev) => {
if (!prev) return prev;
if (prev.columns.some((c) => c.id === payload.column.id)) return prev;
return { ...prev, columns: [...prev.columns, payload.column] };
});
});
socket.on('column_updated', (payload: { column: Column }) => {
setBoard((prev) => {
if (!prev) return prev;
return {
...prev,
columns: prev.columns.map((c) => (c.id === payload.column.id ? payload.column : c)),
};
});
});
socket.on('column_deleted', (payload: { columnId: string }) => {
setBoard((prev) => {
if (!prev) return prev;
return { ...prev, columns: prev.columns.filter((c) => c.id !== payload.columnId) };
});
});
socket.on('columns_reordered', (payload: { columnIdsInOrder: string[] }) => {
setBoard((prev) => {
if (!prev) return prev;
const colMap = new Map(prev.columns.map((c) => [c.id, c]));
const reordered = payload.columnIdsInOrder
.map((id) => colMap.get(id))
.filter(Boolean) as Column[];
return { ...prev, columns: reordered };
});
});
socket.on('board_settings_updated', (payload: { autoMoveStale: boolean }) => {
setBoard((prev) => (prev ? { ...prev, autoMoveStale: payload.autoMoveStale } : prev));
});
socket.on('member_session_revoked', (payload: { message: string }) => {
soundFx.playLock();
alert(payload.message || t('sessionRevokedMsg'));
window.location.href = '/';
});
socket.on('board_refreshed', (freshBoard: Board) => {
setBoard(freshBoard);
});
})
.catch((err) => {
if (!isMounted) return;
clearTimeout(safetyTimeout);
setError(err.message || 'Failed to authenticate or load board');
setLoading(false);
});
return () => {
isMounted = false;
clearTimeout(safetyTimeout);
disconnectSocket();
};
}, [boardId, propHash]);
const handleTicketUpdatedLocally = useCallback((updatedTicket: Ticket) => {
setBoard((prev) => {
if (!prev) return prev;
return {
...prev,
columns: prev.columns.map((col) => ({
...col,
tickets: col.tickets.map((t) => (t.id === updatedTicket.id ? updatedTicket : t)),
})),
};
});
setSelectedTicket((prev) => (prev && prev.id === updatedTicket.id ? updatedTicket : prev));
}, []);
// DnD Sensors setup (Mobile Touch Friendly: 8px threshold)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const columnIds = useMemo(() => board?.columns.map((c) => c.id) || [], [board]);
const handleDragStart = (event: DragStartEvent) => {
soundFx.playClick();
const { active } = event;
const activeData = active.data.current;
if (activeData?.type === 'Ticket') {
setActiveDragItem({ type: 'Ticket', data: activeData.ticket });
} else if (activeData?.type === 'Column') {
setActiveDragItem({ type: 'Column', data: activeData.column });
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over || !board) return;
const activeId = String(active.id);
const overId = String(over.id);
if (activeId === overId) return;
const isActiveTicket = active.data.current?.type === 'Ticket';
const isOverTicket = over.data.current?.type === 'Ticket';
const isOverColumn = over.data.current?.type === 'Column';
if (!isActiveTicket) return;
if (isOverTicket) {
setBoard((prev) => {
if (!prev) return prev;
const sourceCol = prev.columns.find((c) => c.tickets.some((t) => t.id === activeId));
const destCol = prev.columns.find((c) => c.tickets.some((t) => t.id === overId));
if (!sourceCol || !destCol || sourceCol.id === destCol.id) return prev;
// Check WIP limit on destination column
if (destCol.maxWipLimit > 0 && destCol.tickets.length >= destCol.maxWipLimit) {
return prev;
}
const activeTicket = sourceCol.tickets.find((t) => t.id === activeId)!;
const overIndex = destCol.tickets.findIndex((t) => t.id === overId);
const newSourceTickets = sourceCol.tickets.filter((t) => t.id !== activeId);
const newDestTickets = [...destCol.tickets];
newDestTickets.splice(overIndex, 0, { ...activeTicket, columnId: destCol.id, movedAt: new Date().toISOString() });
return {
...prev,
columns: prev.columns.map((c) => {
if (c.id === sourceCol.id) return { ...c, tickets: newSourceTickets };
if (c.id === destCol.id) return { ...c, tickets: newDestTickets };
return c;
}),
};
});
}
if (isOverColumn) {
setBoard((prev) => {
if (!prev) return prev;
const sourceCol = prev.columns.find((c) => c.tickets.some((t) => t.id === activeId));
const destCol = prev.columns.find((c) => c.id === overId);
if (!sourceCol || !destCol || sourceCol.id === destCol.id) return prev;
// Check WIP limit on destination column
if (destCol.maxWipLimit > 0 && destCol.tickets.length >= destCol.maxWipLimit) {
return prev;
}
const activeTicket = sourceCol.tickets.find((t) => t.id === activeId)!;
const newSourceTickets = sourceCol.tickets.filter((t) => t.id !== activeId);
const newDestTickets = [...destCol.tickets, { ...activeTicket, columnId: destCol.id, movedAt: new Date().toISOString() }];
return {
...prev,
columns: prev.columns.map((c) => {
if (c.id === sourceCol.id) return { ...c, tickets: newSourceTickets };
if (c.id === destCol.id) return { ...c, tickets: newDestTickets };
return c;
}),
};
});
}
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveDragItem(null);
const { active, over } = event;
if (!over || !board) return;
const activeId = String(active.id);
const overId = String(over.id);
soundFx.playDrop();
// Column Reordering (Admin only)
if (active.data.current?.type === 'Column' && role === 'admin') {
if (activeId !== overId) {
const oldIndex = board.columns.findIndex((c) => c.id === activeId);
const newIndex = board.columns.findIndex((c) => c.id === overId);
const reordered = arrayMove(board.columns, oldIndex, newIndex);
setBoard({ ...board, columns: reordered });
const socket = getSocket();
if (socket) {
socket.emit('reorder_columns', { columnIdsInOrder: reordered.map((c) => c.id) });
}
}
return;
}
// Ticket Reordering / Move End
if (active.data.current?.type === 'Ticket') {
const currentCol = board.columns.find((c) => c.tickets.some((t) => t.id === activeId));
if (!currentCol) return;
const ticketIndex = currentCol.tickets.findIndex((t) => t.id === activeId);
const ticketIds = currentCol.tickets.map((t) => t.id);
const socket = getSocket();
if (socket) {
socket.emit('card_moved', {
ticketId: activeId,
targetColumnId: currentCol.id,
newOrder: ticketIndex,
ticketIdsInTargetColumn: ticketIds,
});
}
}
};
// Actions: Add Column (Admin)
const handleCreateColumn = (e: React.FormEvent) => {
e.preventDefault();
if (!newColTitle.trim()) return;
soundFx.playClick();
const socket = getSocket();
if (socket) {
socket.emit('create_column', {
title: newColTitle.trim(),
maxWipLimit: newColWip,
autoStaleHours: newColStale,
});
}
setNewColTitle('');
setNewColWip(0);
setNewColStale(0);
setIsAddingColumn(false);
};
// Actions: Update Column Settings (WIP, Stale, Title)
const handleUpdateColumn = (columnId: string, updates: { title?: string; maxWipLimit?: number; autoStaleHours?: number }) => {
const socket = getSocket();
if (socket) {
socket.emit('update_column', { columnId, updates });
}
};
// Actions: Delete Column (Admin)
const handleDeleteColumn = (columnId: string) => {
const socket = getSocket();
if (socket) {
socket.emit('delete_column', { columnId });
}
};
// Actions: Add Ticket
const handleAddTicket = (columnId: string, title: string) => {
const targetCol = board?.columns.find((c) => c.id === columnId);
if (targetCol && targetCol.maxWipLimit > 0 && targetCol.tickets.length >= targetCol.maxWipLimit) {
soundFx.playLock();
alert(t('wipWarningModal'));
return;
}
const socket = getSocket();
if (socket) {
socket.emit('create_ticket', {
columnId,
title,
priority: 'MEDIUM',
tags: [],
subtasks: [],
dueDate: '',
isBlocked: false,
});
}
};
// Actions: Toggle Auto-move stale setting
const handleToggleAutoMoveStale = () => {
if (role !== 'admin' || !board) return;
soundFx.playClick();
const nextVal = !board.autoMoveStale;
setBoard({ ...board, autoMoveStale: nextVal });
const socket = getSocket();
if (socket) {
socket.emit('update_board_settings', { autoMoveStale: nextVal });
}
};
// Actions: Delete Board (Admin)
const handleDeleteBoard = async () => {
if (role !== 'admin') return;
soundFx.playClick();
if (confirm('🚨 Hapus papan Kanban ini secara permanen? Tindakan ini tidak dapat dibatalkan.')) {
try {
await deleteBoard(boardId, key);
window.location.href = '/';
} catch (err: any) {
alert(err.message || 'Failed to delete board');
}
}
};
if (showKeyPrompt) {
return (
<KeyPromptModal
boardId={boardId}
onKeyProvided={(providedKey, roleHint) => {
setShowKeyPrompt(false);
setKey(providedKey);
if (roleHint) setRole(roleHint);
}}
/>
);
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4">
<div className="w-10 h-10 border-3 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
<p className="text-sm font-semibold text-indigo-600 dark:text-indigo-400 animate-pulse">
{t('connectingToRealm')}
</p>
</div>
);
}
if (error || !board) {
return (
<div className="max-w-md mx-auto my-12 sm:my-20 p-5 sm:p-8 card-kanban border-rose-500/50 text-center mx-4">
<AlertCircle size={36} className="text-rose-500 mx-auto mb-3" />
<h2 className="text-base font-bold text-rose-600 dark:text-rose-400 mb-2">
{t('accessForbidden')}
</h2>
<p className="text-xs text-slate-600 dark:text-slate-300 mb-5 leading-relaxed">
{error || t('accessForbiddenDesc')}
</p>
<div className="flex gap-2">
<button onClick={onBackHome} className="btn-modern-secondary flex-1 py-2">
{t('backToHome')}
</button>
<button
onClick={() => {
setShowKeyPrompt(true);
setError(null);
}}
className="btn-modern-primary flex-1 py-2"
>
{t('reEnterKey')}
</button>
</div>
</div>
);
}
return (
<div className="flex flex-col min-h-[calc(100vh-60px)]">
{/* Board Sub-Header */}
<div className="bg-white/95 dark:bg-[#090d18]/90 border-b border-slate-200 dark:border-slate-800 px-3 sm:px-4 py-2.5 sm:py-3 shadow-sm transition-colors duration-200">
<div className="max-w-7xl mx-auto flex flex-wrap items-center justify-between gap-2.5 sm:gap-3">
{/* Board Title & Role Tag */}
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
<button
onClick={() => {
soundFx.playClick();
onBackHome();
}}
className="p-1.5 sm:p-2 bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/80 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition-colors shrink-0"
title={t('backToHome')}
>
<ArrowLeft size={15} />
</button>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm sm:text-base md:text-lg font-bold text-slate-900 dark:text-white tracking-tight truncate max-w-[140px] sm:max-w-[280px] md:max-w-md">
{board.title}
</h2>
<span
className={`text-[10px] sm:text-[11px] font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 border shrink-0 ${
role === 'admin'
? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30'
: 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-500/30'
}`}
>
{role === 'admin' ? <Crown size={11} className="text-amber-600 dark:text-amber-400" /> : <Shield size={11} className="text-indigo-600 dark:text-indigo-400" />}
<span>{role === 'admin' ? t('superadmin') : t('member')}</span>
</span>
</div>
</div>
</div>
{/* Right Action Controls */}
<div className="flex items-center gap-1.5 sm:gap-2 ml-auto">
{/* Active Presence Avatars */}
<div className="flex items-center bg-slate-100 dark:bg-slate-950/70 border border-slate-200 dark:border-slate-800 px-2 sm:px-3 py-1 sm:py-1.5 rounded-lg gap-1.5">
<Users size={13} className="text-indigo-600 dark:text-indigo-400 shrink-0" />
<div className="flex -space-x-1 items-center">
{presenceUsers.slice(0, 4).map((u) => (
<div
key={u.socketId}
style={{ backgroundColor: u.color }}
className="w-4 h-4 sm:w-5 sm:h-5 rounded-full border border-white dark:border-slate-900 flex items-center justify-center text-[8px] sm:text-[9px] text-slate-950 font-bold uppercase relative group cursor-default shadow-sm"
>
{u.userName.charAt(0)}
<span className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-slate-950 text-white text-[10px] px-2 py-1 rounded-md whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-30 border border-slate-800 shadow-md">
{u.userName} ({u.role})
</span>
</div>
))}
</div>
<span className="text-[11px] sm:text-xs font-semibold text-slate-700 dark:text-slate-300">
{presenceUsers.length}
</span>
</div>
{/* Board Activity / Movement Log Button */}
<button
onClick={() => {
soundFx.playClick();
setShowActivityModal(true);
}}
className="btn-modern-secondary text-[11px] sm:text-xs py-1.5 sm:py-2 px-2.5 sm:px-3"
title={t('boardLogsBtn')}
>
<History size={13} className="text-indigo-600 dark:text-indigo-400" />
<span className="hidden sm:inline">{t('boardLogsBtn')}</span>
</button>
{/* Share / Invite Button */}
<button
onClick={() => {
soundFx.playClick();
setShowShareModal(true);
}}
className="btn-modern-primary text-[11px] sm:text-xs py-1.5 sm:py-2 px-2.5 sm:px-3.5"
>
<Share2 size={13} />
<span className="hidden xs:inline">{t('shareInviteBtn')}</span>
</button>
{/* Board Settings Modal Button for Admin */}
{role === 'admin' && (
<button
onClick={() => {
soundFx.playClick();
setShowBoardSettings(!showBoardSettings);
}}
className={`btn-modern text-[11px] sm:text-xs py-1.5 sm:py-2 px-2 sm:px-3 ${
board.autoMoveStale ? 'bg-cyan-600 text-white font-bold' : 'btn-modern-secondary'
}`}
title={t('boardSettings')}
>
<Sliders size={13} />
<span className="hidden sm:inline">{t('boardSettings')}</span>
</button>
)}
{/* Admin Key Rotation Button */}
{role === 'admin' && (
<button
onClick={() => {
soundFx.playClick();
setShowRotateKeyModal(true);
}}
className="btn-modern-gold text-[11px] sm:text-xs py-1.5 sm:py-2 px-2 sm:px-3"
title={t('rotateKeyTooltip')}
>
<RefreshCw size={12} />
<span className="hidden md:inline">{t('rotateKeyBtn')}</span>
</button>
)}
{/* Delete Board Button (Admin) */}
{role === 'admin' && (
<button
onClick={handleDeleteBoard}
className="btn-modern-danger p-1.5 sm:p-2 text-xs"
title={t('deleteBoardTooltip')}
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
{/* Board Settings Bar (Admin dropdown) */}
{showBoardSettings && role === 'admin' && (
<div className="max-w-7xl mx-auto mt-3 p-3 bg-slate-50 dark:bg-slate-950/80 border border-cyan-500/40 rounded-xl flex flex-wrap items-center justify-between gap-2.5 text-xs animate-fade-in shadow-sm">
<div className="flex items-center gap-2 text-cyan-800 dark:text-cyan-300 font-medium text-xs">
<Sliders size={14} />
<span>{t('autoMoveStaleSetting')}</span>
</div>
<button
onClick={handleToggleAutoMoveStale}
className={`btn-modern text-xs py-1.5 px-3 font-semibold ${
board.autoMoveStale ? 'bg-emerald-600 text-white' : 'btn-modern-secondary'
}`}
>
{board.autoMoveStale ? '✅ Aktif (Auto-Move ON)' : '🚫 Nonaktif'}
</button>
</div>
)}
</div>
{/* Main Kanban Board Snap Scroll Canvas */}
<div className="flex-1 p-3 sm:p-4 md:p-6 overflow-x-auto snap-x snap-mandatory scroll-smooth">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex items-start gap-3.5 sm:gap-5 pb-6">
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
{board.columns.map((column) => (
<div key={column.id} className="snap-center">
<SortableContext
items={column.tickets.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<ColumnContainer
column={column}
role={role}
activeLocks={activeLocks}
currentUserId={profile.userId}
onTicketClick={(ticket) => setSelectedTicket(ticket)}
onAddTicket={handleAddTicket}
onDeleteColumn={handleDeleteColumn}
onUpdateColumn={handleUpdateColumn}
/>
</SortableContext>
</div>
))}
</SortableContext>
{/* Add Column Button / Form (Admin Only) */}
{role === 'admin' && (
<div className="w-[82vw] max-w-[320px] sm:w-80 shrink-0 snap-center">
{isAddingColumn ? (
<form
onSubmit={handleCreateColumn}
className="card-kanban border-indigo-500/50 p-4 space-y-3"
>
<input
type="text"
value={newColTitle}
onChange={(e) => setNewColTitle(e.target.value)}
placeholder={t('columnTitlePlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 rounded-lg text-slate-900 dark:text-slate-100 text-xs p-2.5 outline-none focus:border-indigo-500"
autoFocus
/>
<div className="grid grid-cols-2 gap-2 text-xs text-slate-500 dark:text-slate-400">
<div>
<label className="block text-[11px] mb-1 font-medium">WIP Limit:</label>
<input
type="number"
min={0}
value={newColWip}
onChange={(e) => setNewColWip(parseInt(e.target.value, 10) || 0)}
placeholder="0 = unlim"
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 rounded-lg p-1.5 text-slate-900 dark:text-slate-100 text-center"
/>
</div>
<div>
<label className="block text-[11px] mb-1 font-medium">Stale (Jam):</label>
<input
type="number"
min={0}
value={newColStale}
onChange={(e) => setNewColStale(parseInt(e.target.value, 10) || 0)}
placeholder="0 = off"
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 rounded-lg p-1.5 text-slate-900 dark:text-slate-100 text-center"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setIsAddingColumn(false)}
className="btn-modern-secondary text-xs py-1.5 px-3"
>
{t('cancel')}
</button>
<button type="submit" className="btn-modern-primary text-xs py-1.5 px-3 font-bold">
{t('addColumn')}
</button>
</div>
</form>
) : (
<button
onClick={() => {
soundFx.playClick();
setIsAddingColumn(true);
}}
className="w-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-900/40 dark:hover:bg-slate-900/80 border border-dashed border-slate-300 dark:border-slate-800 hover:border-indigo-500 rounded-2xl p-5 text-xs font-semibold text-slate-600 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 flex items-center justify-center gap-2 transition-all shadow-sm select-none"
>
<Plus size={16} />
<span>{t('addColumn')}</span>
</button>
)}
</div>
)}
</div>
{/* DnD Drag Overlay */}
<DragOverlay>
{activeDragItem?.type === 'Ticket' && (
<div className="w-[82vw] max-w-[320px] sm:w-72 opacity-95 rotate-1 scale-105 shadow-2xl pointer-events-none">
<TicketCard
ticket={activeDragItem.data as Ticket}
activeLocks={[]}
currentUserId={profile.userId}
onClick={() => {}}
/>
</div>
)}
</DragOverlay>
</DndContext>
</div>
{/* Ticket Details & Edit Modal with Activity History */}
{selectedTicket && (
<TicketModal
ticket={selectedTicket}
boardId={board.id}
boardKey={key}
activeLocks={activeLocks}
currentUserId={profile.userId}
onClose={() => setSelectedTicket(null)}
onUpdateTicket={(ticketId, updates) => {
handleTicketUpdatedLocally({ ...selectedTicket, ...updates, id: ticketId });
}}
onDeleteTicket={(_ticketId, colId) => {
setBoard((prev) => {
if (!prev) return prev;
return {
...prev,
columns: prev.columns.map((c) =>
c.id === colId ? { ...c, tickets: c.tickets.filter((t) => t.id !== _ticketId) } : c
),
};
});
}}
/>
)}
{/* Board Activity / Movement Logs Modal */}
{showActivityModal && (
<BoardActivityModal
boardId={board.id}
boardKey={key}
onClose={() => setShowActivityModal(false)}
/>
)}
{/* Share / Invite Modal */}
{showShareModal && (
<SuccessShareModal
boardData={{
id: board.id,
title: board.title,
adminKey: role === 'admin' ? key : '***',
memberKey: role === 'member' ? key : '***',
adminUrlFragment: `/b/${board.id}#admin=${role === 'admin' ? key : ''}`,
memberUrlFragment: `/b/${board.id}#member=${role === 'member' ? key : ''}`,
createdAt: board.createdAt,
}}
onEnterBoard={() => setShowShareModal(false)}
/>
)}
{/* Superadmin Key Rotation Modal */}
{showRotateKeyModal && role === 'admin' && (
<RegenerateKeyModal
boardId={board.id}
adminKey={key}
onClose={() => setShowRotateKeyModal(false)}
/>
)}
</div>
);
};
+240
View File
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus, Trash2, GripHorizontal, Settings2, AlertTriangle } from 'lucide-react';
import { Column, Ticket, Role, FieldLock } from '../types/index.js';
import { TicketCard } from './TicketCard.js';
import { ColumnSettingsModal } from './ColumnSettingsModal.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface ColumnContainerProps {
column: Column;
role: Role;
activeLocks: FieldLock[];
currentUserId: string;
onTicketClick: (ticket: Ticket) => void;
onAddTicket: (columnId: string, title: string) => void;
onDeleteColumn: (columnId: string) => void;
onUpdateColumn: (columnId: string, updates: { title?: string; maxWipLimit?: number; autoStaleHours?: number }) => void;
}
export const ColumnContainer: React.FC<ColumnContainerProps> = ({
column,
role,
activeLocks,
currentUserId,
onTicketClick,
onAddTicket,
onDeleteColumn,
onUpdateColumn,
}) => {
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [showSettings, setShowSettings] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
data: {
type: 'Column',
column,
},
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
const isWipExceeded = column.maxWipLimit > 0 && column.tickets.length >= column.maxWipLimit;
const handleCreateTicket = (e: React.FormEvent) => {
e.preventDefault();
if (newTitle.trim()) {
soundFx.playClick();
onAddTicket(column.id, newTitle.trim());
setNewTitle('');
setIsAdding(false);
}
};
const handleDeleteCol = () => {
soundFx.playClick();
if (confirm(t('deleteColConfirm'))) {
onDeleteColumn(column.id);
}
};
return (
<div
ref={setNodeRef}
style={style}
className={`column-kanban w-[82vw] max-w-[320px] sm:w-80 shrink-0 flex flex-col max-h-[calc(100vh-170px)] sm:max-h-[calc(100vh-140px)] select-none ${
isWipExceeded ? 'border-amber-500/60 ring-1 ring-amber-500/30' : ''
}`}
>
{/* Column Header */}
<div className="p-3 sm:p-3.5 flex items-center justify-between border-b border-slate-200 dark:border-slate-800/80">
<div className="flex items-center gap-1.5 sm:gap-2 flex-1 min-w-0">
{/* Drag Handle for Admin */}
{role === 'admin' && (
<button
{...attributes}
{...listeners}
className="text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 cursor-grab active:cursor-grabbing p-1 rounded touch-none"
title={t('dragColTooltip')}
>
<GripHorizontal size={14} />
</button>
)}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<h3 className="text-xs md:text-sm font-bold text-slate-800 dark:text-slate-200 truncate">
{column.title}
</h3>
</div>
</div>
<div className="flex items-center gap-1 sm:gap-1.5 ml-1.5">
{/* WIP Limit / Task Count Badge */}
<span
className={`text-[11px] sm:text-xs font-semibold px-2 py-0.5 rounded-full flex items-center gap-1 border ${
isWipExceeded
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-800 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 animate-pulse'
: 'bg-white dark:bg-slate-800/90 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700/80'
}`}
title={column.maxWipLimit > 0 ? `WIP Limit: Maks ${column.maxWipLimit} kartu` : 'Jumlah tugas'}
>
{isWipExceeded && <AlertTriangle size={10} className="text-amber-600 dark:text-amber-400" />}
<span>
{column.tickets.length}
{column.maxWipLimit > 0 ? `/${column.maxWipLimit}` : ''}
</span>
</span>
{/* Settings button for admin */}
{role === 'admin' && (
<button
onClick={() => {
soundFx.playClick();
setShowSettings(true);
}}
className="text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 p-1 rounded-md transition-colors"
title={t('editColSettings')}
>
<Settings2 size={13} />
</button>
)}
{/* Delete Column button for admin */}
{role === 'admin' && (
<button
onClick={handleDeleteCol}
className="text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 p-1 rounded-md transition-colors"
title="Delete Column"
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
{/* WIP Limit Warning Banner */}
{isWipExceeded && (
<div className="bg-amber-50 dark:bg-amber-500/15 border-b border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300 px-3 py-1 text-xs font-medium flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[11px]">
<AlertTriangle size={12} className="text-amber-600 dark:text-amber-400" />
<span>{t('wipLimitReached')}</span>
</span>
<span className="text-[10px] font-semibold">({column.tickets.length}/{column.maxWipLimit})</span>
</div>
)}
{/* Tickets List Area */}
<div className="p-2.5 sm:p-3 flex-1 overflow-y-auto min-h-[100px]">
{column.tickets.map((ticket) => (
<TicketCard
key={ticket.id}
ticket={ticket}
autoStaleHours={column.autoStaleHours || 0}
activeLocks={activeLocks}
currentUserId={currentUserId}
onClick={() => onTicketClick(ticket)}
/>
))}
{/* Empty state placeholder */}
{column.tickets.length === 0 && !isAdding && (
<div className="border border-dashed border-slate-300 dark:border-slate-800 rounded-xl p-5 sm:p-6 text-center text-slate-400 dark:text-slate-500 text-xs select-none">
{t('noTasksInCol')}
</div>
)}
{/* Inline Add Ticket Form */}
{isAdding && (
<form onSubmit={handleCreateTicket} className="bg-white dark:bg-slate-900 border border-indigo-500/60 rounded-xl p-3 mb-2.5 shadow-sm">
<textarea
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('taskTitlePlaceholder')}
rows={2}
className="w-full bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800 rounded-lg text-slate-900 dark:text-slate-100 text-xs p-2.5 outline-none resize-none mb-2 focus:border-indigo-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCreateTicket(e);
}
}}
/>
<div className="flex items-center justify-between">
<span className="text-[10px] text-slate-400">{t('enterToAdd')}</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setIsAdding(false)}
className="px-2.5 py-1 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
className="px-3 py-1 bg-indigo-600 text-white text-xs font-bold rounded-md hover:bg-indigo-500 transition-colors"
>
{t('add')}
</button>
</div>
</div>
</form>
)}
</div>
{/* Column Footer: Quick Add Button */}
{!isAdding && (
<div className="p-2 sm:p-2.5 border-t border-slate-200 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-900/30 rounded-b-2xl">
<button
onClick={() => {
soundFx.playClick();
setIsAdding(true);
}}
className="w-full py-2 bg-white dark:bg-slate-800/60 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300 text-xs font-medium rounded-lg border border-slate-200 dark:border-slate-700/60 flex items-center justify-center gap-1.5 transition-all shadow-sm"
>
<Plus size={14} className="text-indigo-600 dark:text-indigo-400" />
<span>{t('addNewTask')}</span>
</button>
</div>
)}
{/* Column Settings Modal */}
{showSettings && (
<ColumnSettingsModal
column={column}
onClose={() => setShowSettings(false)}
onSave={(columnId, updates) => onUpdateColumn(columnId, updates)}
/>
)}
</div>
);
};
@@ -0,0 +1,140 @@
import React, { useState } from 'react';
import { Settings2, X, AlertTriangle, Clock } from 'lucide-react';
import { Column } from '../types/index.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface ColumnSettingsModalProps {
column: Column;
onClose: () => void;
onSave: (columnId: string, updates: { title: string; maxWipLimit: number; autoStaleHours: number }) => void;
}
export const ColumnSettingsModal: React.FC<ColumnSettingsModalProps> = ({ column, onClose, onSave }) => {
const { t } = useTranslation();
const [title, setTitle] = useState(column.title);
const [maxWipLimit, setMaxWipLimit] = useState<number>(column.maxWipLimit || 0);
const [autoStaleHours, setAutoStaleHours] = useState<number>(column.autoStaleHours || 0);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
soundFx.playClick();
onSave(column.id, {
title: title.trim() || column.title,
maxWipLimit: Number(maxWipLimit) || 0,
autoStaleHours: Number(autoStaleHours) || 0,
});
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-4 sm:p-6 relative text-slate-900 dark:text-slate-100 max-h-[92vh] overflow-y-auto">
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 p-1 rounded-md"
>
<X size={18} />
</button>
<div className="flex items-center gap-3 mb-4 sm:mb-5">
<div className="w-9 h-9 sm:w-10 sm:h-10 bg-indigo-50 dark:bg-emerald-500/10 border border-indigo-200 dark:border-emerald-500/30 rounded-xl flex items-center justify-center text-indigo-600 dark:text-emerald-400">
<Settings2 size={18} />
</div>
<div>
<h2 className="text-sm sm:text-base font-bold text-slate-900 dark:text-slate-100">
{t('editColSettings')}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[200px]">
{column.title}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-3.5 text-xs">
{/* Column Title */}
<div>
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
{t('taskTitle')}
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 rounded-lg p-2.5 text-slate-900 dark:text-slate-100 text-xs outline-none focus:border-indigo-500"
required
/>
</div>
{/* WIP Limit Setting */}
<div className="bg-amber-50/70 dark:bg-slate-950/70 border border-amber-200 dark:border-amber-500/30 p-3.5 rounded-xl">
<label className="flex items-center gap-1.5 text-xs font-semibold text-amber-800 dark:text-amber-400 mb-1">
<AlertTriangle size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
<span>{t('wipLimitLabel')}</span>
</label>
<p className="text-[11px] text-slate-600 dark:text-slate-400 mb-2 leading-relaxed">
Membatasi jumlah kartu aktif di kolom ini agar tim fokus menyelesaikan tugas sebelum mengambil yang baru.
</p>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={50}
value={maxWipLimit}
onChange={(e) => setMaxWipLimit(parseInt(e.target.value, 10) || 0)}
className="w-20 sm:w-24 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-lg p-2 text-slate-900 dark:text-slate-100 text-xs text-center outline-none"
/>
<span className="text-[11px] sm:text-xs text-amber-800 dark:text-amber-300 font-medium">
{maxWipLimit === 0 ? `(${t('wipUnlimited')})` : `(Maks. ${maxWipLimit} kartu)`}
</span>
</div>
</div>
{/* Auto-Stale Hours Setting */}
<div className="bg-cyan-50/70 dark:bg-slate-950/70 border border-cyan-200 dark:border-cyan-500/30 p-3.5 rounded-xl">
<label className="flex items-center gap-1.5 text-xs font-semibold text-cyan-800 dark:text-cyan-400 mb-1">
<Clock size={14} className="text-cyan-600 dark:text-cyan-400 shrink-0" />
<span>{t('autoStaleHoursLabel')}</span>
</label>
<p className="text-[11px] text-slate-600 dark:text-slate-400 mb-2 leading-relaxed">
Memberikan tanda peringatan jika kartu tidak bergerak di kolom ini melebihi durasi yang ditentukan.
</p>
<div className="flex items-center gap-2">
<select
value={autoStaleHours}
onChange={(e) => setAutoStaleHours(parseInt(e.target.value, 10) || 0)}
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 rounded-lg p-2 text-slate-900 dark:text-slate-100 text-xs outline-none flex-1"
>
<option value={0}>🚫 Nonaktif (0 Jam)</option>
<option value={12}> 12 Jam</option>
<option value={24}> 24 Jam (1 Hari) [Standar]</option>
<option value={48}> 48 Jam (2 Hari)</option>
<option value={72}> 72 Jam (3 Hari)</option>
<option value={168}> 7 Hari</option>
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => {
soundFx.playClick();
onClose();
}}
className="btn-modern-secondary py-2 px-4"
>
{t('cancel')}
</button>
<button type="submit" className="btn-modern-primary py-2 px-5 font-bold">
{t('save')}
</button>
</div>
</form>
</div>
</div>
);
};
+221
View File
@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react';
import { Sparkles, Trash2, ArrowRight, ShieldAlert, Link2, PlusCircle, CheckCircle2 } from 'lucide-react';
import { createBoard } from '../services/api.js';
import { BoardCreationResponse, SavedBoard } from '../types/index.js';
import { getSavedBoards, removeSavedBoard, saveBoardToStorage } from '../utils/storage.js';
import { SuccessShareModal } from './SuccessShareModal.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface HomePageProps {
onOpenBoard: (boardId: string, fragment: string) => void;
}
export const HomePage: React.FC<HomePageProps> = ({ onOpenBoard }) => {
const { t } = useTranslation();
const [boardTitle, setBoardTitle] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdBoard, setCreatedBoard] = useState<BoardCreationResponse | null>(null);
const [savedBoards, setSavedBoards] = useState<(SavedBoard & { key?: string })[]>([]);
useEffect(() => {
setSavedBoards(getSavedBoards());
}, []);
const handleCreateBoard = async (e: React.FormEvent) => {
e.preventDefault();
soundFx.playClick();
setLoading(true);
setError(null);
try {
const result = await createBoard(boardTitle.trim() || 'Sprint Agile Board');
setCreatedBoard(result);
// Save to localStorage
saveBoardToStorage({
id: result.id,
title: result.title,
role: 'admin',
key: result.adminKey,
});
setSavedBoards(getSavedBoards());
} catch (err: any) {
setError(err.message || 'Failed to create board');
soundFx.playLock();
} finally {
setLoading(false);
}
};
const handleOpenSaved = (b: SavedBoard & { key?: string }) => {
soundFx.playClick();
let hash = '';
if (b.url && b.url.includes('#')) {
hash = '#' + b.url.split('#')[1];
} else if (b.key) {
hash = b.role === 'admin' ? `#admin=${b.key}` : `#member=${b.key}`;
}
onOpenBoard(b.id, hash);
};
const handleRemoveSaved = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
soundFx.playClick();
removeSavedBoard(id);
setSavedBoards(getSavedBoards());
};
const handleEnterCreatedBoard = () => {
if (!createdBoard) return;
window.location.hash = `admin=${createdBoard.adminKey}`;
onOpenBoard(createdBoard.id, `#admin=${createdBoard.adminKey}`);
};
return (
<div className="max-w-4xl mx-auto px-4 py-8 md:py-14">
{/* Hero Section */}
<div className="text-center mb-10 md:mb-12">
<div className="inline-flex items-center gap-2 bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/25 text-indigo-700 dark:text-indigo-300 text-xs px-3.5 py-1.5 rounded-full mb-4 shadow-sm">
<Sparkles size={14} className="text-indigo-600 dark:text-indigo-400" />
<span className="font-semibold tracking-wide">{t('heroBadge')}</span>
</div>
<h1 className="text-3xl md:text-5xl font-extrabold text-slate-900 dark:text-white tracking-tight mb-4 leading-tight">
Taut<span className="text-indigo-600 dark:text-indigo-400">Kan</span> <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 via-purple-600 to-cyan-600 dark:from-indigo-400 dark:via-purple-300 dark:to-cyan-400 text-2xl md:text-4xl font-bold">
{t('heroTitle')}
</span>
</h1>
<p className="text-slate-600 dark:text-slate-300 text-sm md:text-base max-w-xl mx-auto leading-relaxed">
{t('heroSubtitle')}
</p>
</div>
{/* Create Board Card */}
<div className="card-kanban p-6 md:p-8 mb-10 relative overflow-hidden">
<div className="absolute -top-12 -right-12 w-48 h-48 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none"></div>
<h2 className="text-base md:text-lg font-bold text-slate-900 dark:text-white mb-4 flex items-center gap-2.5">
<PlusCircle size={20} className="text-indigo-600 dark:text-indigo-400" />
<span>{t('createBoardHeader')}</span>
</h2>
{error && (
<div className="bg-rose-50 dark:bg-rose-950/70 border border-rose-200 dark:border-rose-500/50 p-3.5 mb-5 text-xs text-rose-700 dark:text-rose-200 rounded-lg flex items-center gap-2.5">
<ShieldAlert size={16} className="text-rose-600 dark:text-rose-400 shrink-0" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleCreateBoard} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-2">
{t('boardTitleLabel')}
</label>
<input
type="text"
value={boardTitle}
onChange={(e) => setBoardTitle(e.target.value)}
placeholder={t('boardTitlePlaceholder')}
className="w-full bg-slate-50 dark:bg-[#080c18] border border-slate-300 dark:border-slate-800 focus:border-indigo-500 text-slate-900 dark:text-white text-sm p-3.5 rounded-lg outline-none transition-colors"
/>
</div>
<div className="pt-2">
<button
type="submit"
disabled={loading}
className="btn-modern-primary w-full py-3.5 text-sm flex items-center justify-center gap-2 font-bold shadow-md shadow-indigo-600/25"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>{t('creatingBoardBtn')}</span>
</>
) : (
<>
<Link2 size={16} />
<span>{t('createBoardBtn')}</span>
<ArrowRight size={16} />
</>
)}
</button>
</div>
</form>
<div className="mt-4 flex flex-wrap items-center justify-between text-xs text-slate-500 dark:text-slate-400 gap-2">
<span className="flex items-center gap-1">🛡 {t('rateLimitNote')}</span>
<span className="flex items-center gap-1">🗝 {t('rbacNote')}</span>
</div>
</div>
{/* Saved Boards Section */}
{savedBoards.length > 0 && (
<div className="card-kanban p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm md:text-base font-bold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<CheckCircle2 size={16} className="text-indigo-600 dark:text-indigo-400" />
<span>{t('savedBoardsTitle')} ({savedBoards.length})</span>
</h3>
<span className="text-xs text-slate-500 dark:text-slate-400">
{t('savedBoardsSub')}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{savedBoards.map((b) => (
<div
key={b.id}
onClick={() => handleOpenSaved(b)}
className="bg-slate-50 dark:bg-[#0b1020]/70 hover:bg-slate-100 dark:hover:bg-[#121830] border border-slate-200 dark:border-slate-800 p-3.5 rounded-lg transition-all cursor-pointer flex items-center justify-between group"
>
<div className="min-w-0 flex-1 pr-2">
<div className="flex items-center gap-1.5 mb-1">
<span
className={`text-[10px] font-semibold px-2 py-0.5 rounded-full border ${
b.role === 'admin'
? 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30'
: 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-500/30'
}`}
>
{b.role === 'admin' ? `👑 ${t('superadmin')}` : `👤 ${t('member')}`}
</span>
<span className="text-[11px] text-slate-500 dark:text-slate-400 truncate">
{new Date(b.lastVisited).toLocaleDateString()}
</span>
</div>
<h4 className="text-xs font-bold text-slate-900 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors truncate">
{b.title}
</h4>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => handleRemoveSaved(e, b.id)}
className="text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 p-1.5 transition-colors"
title={t('removeSaved')}
>
<Trash2 size={14} />
</button>
<ArrowRight size={14} className="text-slate-400 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors" />
</div>
</div>
))}
</div>
</div>
)}
{/* Success Modal upon board creation */}
{createdBoard && (
<SuccessShareModal
boardData={createdBoard}
onEnterBoard={handleEnterCreatedBoard}
/>
)}
</div>
);
};
+101
View File
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { Key, ShieldAlert } from 'lucide-react';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface KeyPromptModalProps {
boardId?: string;
onKeyProvided: (key: string, roleHint?: 'admin' | 'member') => void;
}
export const KeyPromptModal: React.FC<KeyPromptModalProps> = ({ onKeyProvided }) => {
const { t } = useTranslation();
const [inputVal, setInputVal] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
soundFx.playClick();
const val = inputVal.trim();
if (!val) {
setError(t('keyPromptError'));
return;
}
// Check if it's a full URL
if (val.includes('#admin=')) {
const key = val.split('#admin=')[1]?.split('&')[0];
if (key) {
window.location.hash = `admin=${key}`;
onKeyProvided(key, 'admin');
return;
}
} else if (val.includes('#member=')) {
const key = val.split('#member=')[1]?.split('&')[0];
if (key) {
window.location.hash = `member=${key}`;
onKeyProvided(key, 'member');
return;
}
}
// Direct key entered
window.location.hash = `member=${val}`;
onKeyProvided(val);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-md w-full p-4 sm:p-7 text-center text-slate-900 dark:text-slate-100 max-h-[92vh] overflow-y-auto">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-2xl mx-auto flex items-center justify-center mb-3 text-amber-600 dark:text-amber-400">
<Key size={22} />
</div>
<h2 className="text-sm sm:text-base md:text-lg font-bold text-slate-900 dark:text-slate-100 mb-1.5">
{t('keyPromptTitle')}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-4 sm:mb-5 leading-relaxed">
{t('keyPromptDesc')}
</p>
{error && (
<div className="bg-rose-50 dark:bg-rose-500/15 border border-rose-200 dark:border-rose-500/30 text-rose-700 dark:text-rose-300 text-xs p-2.5 sm:p-3 rounded-lg mb-4 flex items-center gap-2 text-left">
<ShieldAlert size={15} className="shrink-0 text-rose-600 dark:text-rose-400" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3.5">
<input
type="text"
value={inputVal}
onChange={(e) => {
setInputVal(e.target.value);
setError('');
}}
placeholder={t('keyPromptPlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 rounded-lg text-indigo-600 dark:text-emerald-400 font-mono text-xs p-3 outline-none focus:border-indigo-500"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
soundFx.playClick();
window.location.href = '/';
}}
className="btn-modern-secondary flex-1 py-2.5 text-xs"
>
{t('backToHome')}
</button>
<button type="submit" className="btn-modern-primary flex-1 py-2.5 font-bold text-xs">
{t('unlockBoardBtn')}
</button>
</div>
</form>
</div>
</div>
);
};
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useEffect } from 'react';
import { Volume2, VolumeX, User, Sun, Moon, Globe, Link2, Gamepad2, Sparkles } from 'lucide-react';
import { soundFx } from '../utils/sound.js';
import { getUserProfile, setUserName } from '../utils/storage.js';
import { getTheme, toggleTheme, Theme } from '../utils/theme.js';
import { getLanguage, setLanguage, useTranslation, Language } from '../utils/i18n.js';
import { getRetroMode, toggleRetroMode } from '../utils/mode.js';
interface NavbarProps {
onHomeClick?: () => void;
onLanguageChange?: (lang: Language) => void;
onThemeChange?: (theme: Theme) => void;
onRetroModeChange?: (isRetro: boolean) => void;
}
export const Navbar: React.FC<NavbarProps> = ({
onHomeClick,
onLanguageChange,
onThemeChange,
onRetroModeChange,
}) => {
const { t } = useTranslation();
const [soundEnabled, setSoundEnabled] = useState(soundFx.isEnabled());
const [currentTheme, setCurrentTheme] = useState<Theme>(getTheme());
const [currentLang, setCurrentLangState] = useState<Language>(getLanguage());
const [currentRetro, setCurrentRetroState] = useState<boolean>(getRetroMode());
const [profile, setProfileState] = useState(getUserProfile());
const [isEditingName, setIsEditingName] = useState(false);
const [tempName, setTempName] = useState(profile.userName);
useEffect(() => {
const handleProfileUpdate = (e: Event) => {
const customEvent = e as CustomEvent<{ userId: string; userName: string }>;
if (customEvent.detail) {
setProfileState(customEvent.detail);
setTempName(customEvent.detail.userName);
}
};
window.addEventListener('user_profile_updated', handleProfileUpdate);
return () => window.removeEventListener('user_profile_updated', handleProfileUpdate);
}, []);
const handleToggleSound = () => {
const next = soundFx.toggle();
setSoundEnabled(next);
};
const handleToggleTheme = () => {
soundFx.playClick();
const next = toggleTheme();
setCurrentTheme(next);
if (onThemeChange) onThemeChange(next);
};
const handleToggleRetro = () => {
soundFx.playClick();
const next = toggleRetroMode();
setCurrentRetroState(next);
if (onRetroModeChange) onRetroModeChange(next);
};
const handleToggleLanguage = () => {
soundFx.playClick();
const next: Language = currentLang === 'id' ? 'en' : 'id';
setLanguage(next);
setCurrentLangState(next);
if (onLanguageChange) onLanguageChange(next);
};
const handleSaveName = (e: React.FormEvent) => {
e.preventDefault();
if (tempName.trim()) {
setUserName(tempName.trim());
setProfileState(getUserProfile());
setIsEditingName(false);
soundFx.playClick();
}
};
return (
<header className="bg-white/95 dark:bg-[#090d18]/95 border-b border-slate-200 dark:border-slate-800/80 px-2.5 sm:px-4 py-2.5 sm:py-3 sticky top-0 z-40 backdrop-blur-md transition-colors duration-200">
<div className="max-w-7xl mx-auto flex items-center justify-between gap-2">
{/* Brand Logo & Tagline */}
<div
onClick={() => {
soundFx.playClick();
if (onHomeClick) onHomeClick();
else window.location.href = '/';
}}
className="flex items-center gap-2 sm:gap-3 cursor-pointer group select-none shrink-0"
>
<div className="w-8 h-8 sm:w-9 sm:h-9 bg-gradient-to-br from-indigo-500 to-cyan-500 rounded-lg flex items-center justify-center shadow-md shadow-indigo-500/20 group-hover:scale-105 transition-all">
<Link2 size={18} className="text-white stroke-[2.5]" />
</div>
<div>
<div className="flex items-center gap-1.5">
<h1 className="text-sm sm:text-base md:text-lg font-bold text-slate-900 dark:text-white tracking-tight">
Taut<span className="text-indigo-600 dark:text-indigo-400">Kan</span>
</h1>
<span className="text-[9px] sm:text-[10px] bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30 px-1.5 sm:px-2 py-0.5 rounded-full font-medium hidden xs:inline-block">
{t('appBadge')}
</span>
</div>
<p className="text-[10px] sm:text-[11px] text-slate-500 dark:text-slate-400 hidden md:block">
{t('appTagline')}
</p>
</div>
</div>
{/* Action Controls */}
<div className="flex items-center gap-1 sm:gap-2">
{/* User Display Name */}
<div className="relative">
{isEditingName ? (
<form onSubmit={handleSaveName} className="flex items-center gap-1">
<input
type="text"
maxLength={18}
value={tempName}
onChange={(e) => setTempName(e.target.value)}
className="bg-white dark:bg-slate-900 border border-indigo-500 text-slate-900 dark:text-indigo-300 text-xs px-2 py-1 rounded-md outline-none w-20 sm:w-32"
autoFocus
onBlur={() => setIsEditingName(false)}
/>
<button type="submit" className="bg-indigo-600 text-white text-[10px] font-bold px-1.5 py-1 rounded-md">
OK
</button>
</form>
) : (
<button
onClick={() => {
soundFx.playClick();
setIsEditingName(true);
}}
className="flex items-center gap-1 bg-slate-100 dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 px-2 py-1.5 text-xs text-slate-700 dark:text-slate-200 rounded-lg hover:border-indigo-500 transition-all"
title={t('changeName')}
>
<User size={12} className="text-indigo-600 dark:text-indigo-400 shrink-0" />
<span className="max-w-[50px] xs:max-w-[70px] sm:max-w-[120px] truncate text-[11px]">{profile.userName}</span>
<span className="text-[9px] text-slate-400"></span>
</button>
)}
</div>
{/* 8-Bit Easter Egg Toggle Switch */}
<button
onClick={handleToggleRetro}
className={`flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-lg transition-all border ${
currentRetro
? 'bg-purple-100 dark:bg-purple-900/70 border-purple-300 dark:border-purple-400 text-purple-900 dark:text-purple-200 shadow-sm'
: 'bg-slate-100 dark:bg-slate-900/90 border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 hover:border-purple-400 hover:text-purple-600 dark:hover:text-purple-300'
}`}
title="Toggle 8-Bit Retro RPG Mode"
>
{currentRetro ? <Sparkles size={12} className="text-purple-600 dark:text-purple-300" /> : <Gamepad2 size={12} className="text-purple-600 dark:text-purple-400" />}
<span className="hidden sm:inline-block font-semibold text-[11px]">
{currentRetro ? '✨ Modern' : '🕹️ 8-Bit'}
</span>
</button>
{/* Language Switcher */}
<button
onClick={handleToggleLanguage}
className="flex items-center gap-0.5 bg-slate-100 dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 px-2 py-1.5 text-[11px] font-medium text-slate-700 dark:text-slate-200 rounded-lg hover:border-indigo-500 transition-all"
title="Switch Language / Ganti Bahasa"
>
<Globe size={12} className="text-slate-500 dark:text-slate-400" />
<span>{currentLang === 'id' ? 'ID' : 'EN'}</span>
</button>
{/* Theme Toggle (Light / Dark) */}
<button
onClick={handleToggleTheme}
className="p-1.5 sm:p-2 border border-slate-200 dark:border-slate-800 bg-slate-100 dark:bg-slate-900/90 text-slate-700 dark:text-slate-300 rounded-lg hover:border-amber-400 transition-all"
title={currentTheme === 'dark' ? t('lightMode') : t('darkMode')}
>
{currentTheme === 'dark' ? <Sun size={13} className="text-amber-400" /> : <Moon size={13} className="text-indigo-600" />}
</button>
{/* Sound Toggle */}
<button
onClick={handleToggleSound}
className={`p-1.5 sm:p-2 border rounded-lg transition-all ${
soundEnabled
? 'bg-indigo-50 dark:bg-indigo-500/15 border-indigo-200 dark:border-indigo-500/40 text-indigo-600 dark:text-indigo-400'
: 'bg-slate-100 dark:bg-slate-900/90 border-slate-200 dark:border-slate-800 text-slate-400 dark:text-slate-500'
}`}
title={soundEnabled ? t('muteAudio') : t('unmuteAudio')}
>
{soundEnabled ? <Volume2 size={13} /> : <VolumeX size={13} />}
</button>
</div>
</div>
</header>
);
};
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { RefreshCw, ShieldAlert, Check, Copy, X } from 'lucide-react';
import { rotateMemberKey } from '../services/api.js';
import { getSocket } from '../services/socket.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface RegenerateKeyModalProps {
boardId: string;
adminKey: string;
onClose: () => void;
}
export const RegenerateKeyModal: React.FC<RegenerateKeyModalProps> = ({ boardId, adminKey, onClose }) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [newMemberUrl, setNewMemberUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const handleRotate = async () => {
soundFx.playClick();
setLoading(true);
setError('');
try {
const res = await rotateMemberKey(boardId, adminKey);
const fullUrl = `${window.location.origin}${res.memberUrlFragment}`;
setNewMemberUrl(fullUrl);
const socket = getSocket();
if (socket) {
socket.emit('member_key_rotated');
}
soundFx.playVictory();
} catch (err: any) {
setError(err.message || 'Failed to rotate key');
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!newMemberUrl) return;
soundFx.playClick();
await navigator.clipboard.writeText(newMemberUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-lg w-full p-4 sm:p-7 relative text-slate-900 dark:text-slate-100 max-h-[92vh] overflow-y-auto">
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="absolute top-4 right-4 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 p-1 rounded-md"
>
<X size={18} />
</button>
<div className="flex items-center gap-3 mb-4 sm:mb-5">
<div className="w-9 h-9 sm:w-10 sm:h-10 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-xl flex items-center justify-center text-amber-600 dark:text-amber-400">
<RefreshCw size={18} />
</div>
<div>
<h2 className="text-sm sm:text-base font-bold text-slate-900 dark:text-slate-100">
{t('keyRotationTitle')}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
{t('keyRotationSubtitle')}
</p>
</div>
</div>
{!newMemberUrl ? (
<div className="space-y-3.5">
<div className="bg-rose-50 dark:bg-rose-500/15 border border-rose-200 dark:border-rose-500/30 p-3.5 rounded-xl text-xs text-rose-800 dark:text-rose-200 flex items-start gap-2.5">
<ShieldAlert size={18} className="text-rose-600 dark:text-rose-400 shrink-0 mt-0.5" />
<div>
<p className="font-bold mb-1 text-rose-700 dark:text-rose-300">{t('keyRotationWarningTitle')}</p>
<p className="text-xs leading-relaxed opacity-90">
{t('keyRotationWarningDesc')}
</p>
</div>
</div>
{error && <p className="text-xs text-rose-600 dark:text-rose-400 font-bold">{error}</p>}
<div className="flex justify-end gap-2 pt-2">
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="btn-modern-secondary py-2 px-4"
>
{t('cancel')}
</button>
<button
onClick={handleRotate}
disabled={loading}
className="btn-modern-danger py-2 px-4 font-bold flex items-center gap-2"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
<span>{loading ? t('rotatingBtn') : t('regenerateBtn')}</span>
</button>
</div>
</div>
) : (
<div className="space-y-3.5">
<div className="bg-emerald-50 dark:bg-emerald-500/15 border border-emerald-200 dark:border-emerald-500/30 p-3.5 rounded-xl text-xs text-emerald-800 dark:text-emerald-200">
<p className="font-bold text-emerald-700 dark:text-emerald-400 mb-1 text-xs">
{t('newLinkActiveTitle')}
</p>
<p className="text-xs leading-relaxed opacity-90">{t('newLinkActiveDesc')}</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={newMemberUrl}
className="bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 text-slate-800 dark:text-slate-300 font-mono text-[11px] sm:text-xs p-2.5 rounded-lg flex-1 select-all outline-none"
/>
<button
onClick={handleCopy}
className={`btn-modern ${copied ? 'bg-emerald-500 text-slate-950' : 'btn-modern-primary'} py-2.5 px-3 flex items-center gap-1.5`}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? t('copied') : t('copy')}</span>
</button>
</div>
<div className="flex justify-end pt-2">
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="btn-modern-primary py-2 px-6 font-bold"
>
{t('done')}
</button>
</div>
</div>
)}
</div>
</div>
);
};
+173
View File
@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import confetti from 'canvas-confetti';
import { Copy, Check, Share2, ShieldAlert, Users, Crown, ArrowRight, Sparkles } from 'lucide-react';
import { BoardCreationResponse } from '../types/index.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface SuccessShareModalProps {
boardData: BoardCreationResponse;
onEnterBoard: () => void;
}
export const SuccessShareModal: React.FC<SuccessShareModalProps> = ({ boardData, onEnterBoard }) => {
const { t } = useTranslation();
const [copiedAdmin, setCopiedAdmin] = useState(false);
const [copiedMember, setCopiedMember] = useState(false);
const adminUrl = `${window.location.origin}${boardData.adminUrlFragment}`;
const memberUrl = `${window.location.origin}${boardData.memberUrlFragment}`;
useEffect(() => {
soundFx.playVictory();
try {
confetti({
particleCount: 70,
spread: 60,
origin: { y: 0.6 },
colors: ['#10b981', '#f59e0b', '#06b6d4', '#8b5cf6'],
});
} catch {}
}, []);
const copyToClipboard = async (text: string, type: 'admin' | 'member') => {
soundFx.playClick();
try {
await navigator.clipboard.writeText(text);
if (type === 'admin') {
setCopiedAdmin(true);
setTimeout(() => setCopiedAdmin(false), 2500);
} else {
setCopiedMember(true);
setTimeout(() => setCopiedMember(false), 2500);
}
} catch (err) {
console.error('Failed to copy link:', err);
}
};
const handleWebShare = async () => {
soundFx.playClick();
if (typeof navigator !== 'undefined' && navigator.share) {
try {
await navigator.share({
title: `TautKan Board: ${boardData.title}`,
text: `Bergabung di papan Agile TautKan: ${boardData.title}`,
url: memberUrl,
});
} catch (err: any) {
if (err.name !== 'AbortError') {
console.error('Error sharing:', err);
}
}
} else {
copyToClipboard(memberUrl, 'member');
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-xl w-full p-4 sm:p-7 relative overflow-hidden text-slate-900 dark:text-slate-100 max-h-[92vh] overflow-y-auto">
{/* Header Banner */}
<div className="text-center mb-5 sm:mb-6">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/30 rounded-2xl mx-auto flex items-center justify-center mb-2.5 text-emerald-600 dark:text-emerald-400">
<Sparkles size={22} />
</div>
<h2 className="text-base sm:text-lg md:text-xl font-bold text-slate-900 dark:text-white tracking-tight">
{t('boardCreatedTitle')}
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{t('boardName')}: <span className="font-semibold text-indigo-600 dark:text-emerald-400">{boardData.title}</span>
</p>
</div>
<div className="space-y-3.5 text-xs">
{/* Creator Link (Superadmin) */}
<div className="bg-amber-50/70 dark:bg-slate-950/70 border border-amber-200 dark:border-amber-500/30 p-3.5 sm:p-4 rounded-xl">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-amber-800 dark:text-amber-400 flex items-center gap-1.5 text-xs">
<Crown size={14} className="text-amber-600 dark:text-amber-400 shrink-0" />
<span>{t('adminLinkTitle')}</span>
</span>
<span className="text-[9px] sm:text-[10px] font-semibold bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30 px-2 py-0.5 rounded-full">
{t('adminLinkWarning')}
</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2 mt-2">
<input
type="text"
readOnly
value={adminUrl}
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 text-slate-800 dark:text-slate-300 font-mono text-[11px] sm:text-xs p-2 sm:p-2.5 rounded-lg flex-1 select-all outline-none truncate"
/>
<button
onClick={() => copyToClipboard(adminUrl, 'admin')}
className={`btn-modern ${copiedAdmin ? 'bg-emerald-500 text-slate-950' : 'btn-modern-gold'} py-2 px-2.5 sm:px-3 text-xs shrink-0`}
>
{copiedAdmin ? <Check size={13} /> : <Copy size={13} />}
<span>{copiedAdmin ? t('copied') : t('copy')}</span>
</button>
</div>
<p className="text-[10px] sm:text-[11px] text-amber-800 dark:text-amber-300/80 mt-2 flex items-center gap-1">
<ShieldAlert size={12} className="text-amber-600 dark:text-amber-400 shrink-0" />
<span>{t('adminLinkSavedNote')}</span>
</p>
</div>
{/* Member Invite Link */}
<div className="bg-emerald-50/70 dark:bg-slate-950/70 border border-emerald-200 dark:border-emerald-500/30 p-3.5 sm:p-4 rounded-xl">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-emerald-800 dark:text-emerald-400 flex items-center gap-1.5 text-xs">
<Users size={14} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
<span>{t('memberLinkTitle')}</span>
</span>
<span className="text-[9px] sm:text-[10px] font-semibold bg-emerald-100 dark:bg-emerald-500/15 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-500/30 px-2 py-0.5 rounded-full">
{t('memberLinkBadge')}
</span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2 mt-2">
<input
type="text"
readOnly
value={memberUrl}
className="bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 text-slate-800 dark:text-slate-300 font-mono text-[11px] sm:text-xs p-2 sm:p-2.5 rounded-lg flex-1 select-all outline-none truncate"
/>
<button
onClick={() => copyToClipboard(memberUrl, 'member')}
className={`btn-modern ${copiedMember ? 'bg-emerald-500 text-slate-950' : 'btn-modern-primary'} py-2 px-2.5 sm:px-3 text-xs shrink-0`}
>
{copiedMember ? <Check size={13} /> : <Copy size={13} />}
<span>{copiedMember ? t('copied') : t('copy')}</span>
</button>
</div>
{/* Web Share API Button for WhatsApp / Mobile */}
<div className="mt-2.5">
<button
onClick={handleWebShare}
className="btn-modern w-full bg-cyan-50 dark:bg-cyan-500/15 hover:bg-cyan-100 dark:hover:bg-cyan-500/25 text-cyan-800 dark:text-cyan-300 border border-cyan-300 dark:border-cyan-500/40 flex items-center justify-center gap-2 py-2 text-xs font-semibold"
>
<Share2 size={13} />
<span>{t('shareWhatsApp')}</span>
</button>
</div>
</div>
</div>
{/* Enter Board Action */}
<div className="mt-5 sm:mt-6 flex justify-end">
<button
onClick={() => {
soundFx.playClick();
onEnterBoard();
}}
className="btn-modern-primary w-full sm:w-auto flex items-center justify-center gap-2 text-xs py-2.5 px-6 font-bold shadow-md"
>
<span>{t('enterBoardBtn')}</span>
<ArrowRight size={15} />
</button>
</div>
</div>
</div>
);
};
+236
View File
@@ -0,0 +1,236 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock, User, CheckSquare, Calendar, AlertOctagon, Clock } from 'lucide-react';
import { Ticket, Priority, FieldLock } from '../types/index.js';
import { soundFx } from '../utils/sound.js';
import { useTranslation } from '../utils/i18n.js';
interface TicketCardProps {
ticket: Ticket;
autoStaleHours?: number;
activeLocks: FieldLock[];
currentUserId: string;
onClick: () => void;
}
export const TicketCard: React.FC<TicketCardProps> = ({
ticket,
autoStaleHours = 0,
activeLocks,
currentUserId,
onClick,
}) => {
const { t, isRetro } = useTranslation();
const PRIORITY_THEMES: Record<Priority, { label: string; badge: string }> = {
LOW: {
label: t('prioLow'),
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/60 dark:text-emerald-300 dark:border-emerald-500/20',
},
MEDIUM: {
label: t('prioMedium'),
badge: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/60 dark:text-blue-300 dark:border-blue-500/20',
},
HIGH: {
label: t('prioHigh'),
badge: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/60 dark:text-amber-300 dark:border-amber-500/20',
},
URGENT: {
label: t('prioUrgent'),
badge: 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/60 dark:text-rose-300 dark:border-rose-500/20',
},
};
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: ticket.id,
data: {
type: 'Ticket',
ticket,
},
});
const style = {
transform: CSS.Translate.toString(transform),
transition,
opacity: isDragging ? 0.35 : 1,
};
const isLockedByOther = activeLocks.some(
(l) => l.ticketId === ticket.id && l.userId !== currentUserId
);
const lockInfo = activeLocks.find(
(l) => l.ticketId === ticket.id && l.userId !== currentUserId
);
// Calculate Due Date Status
let dueDateStatus: 'overdue' | 'today' | 'upcoming' | null = null;
if (ticket.dueDate) {
const today = new Date().toISOString().split('T')[0];
if (ticket.dueDate < today) {
dueDateStatus = 'overdue';
} else if (ticket.dueDate === today) {
dueDateStatus = 'today';
} else {
dueDateStatus = 'upcoming';
}
}
// Calculate Subtask Progress
const totalSubtasks = ticket.subtasks ? ticket.subtasks.length : 0;
const completedSubtasks = ticket.subtasks ? ticket.subtasks.filter((st) => st.completed).length : 0;
// Calculate Stale Status (> autoStaleHours)
let isStale = false;
let staleHoursCount = 0;
if (autoStaleHours > 0 && ticket.movedAt) {
const movedTime = new Date(ticket.movedAt).getTime();
const hoursInCol = (Date.now() - movedTime) / (1000 * 60 * 60);
staleHoursCount = Math.floor(hoursInCol);
if (hoursInCol >= autoStaleHours) {
isStale = true;
}
}
const prioTheme = PRIORITY_THEMES[ticket.priority] || PRIORITY_THEMES.MEDIUM;
return (
<div
ref={setNodeRef}
style={style}
className={`ticket-kanban group relative p-3.5 mb-2.5 transition-all select-none ${
ticket.isBlocked
? 'border-rose-300 dark:border-rose-500/60 bg-rose-50/70 dark:bg-rose-950/20'
: ''
} ${isLockedByOther ? 'border-amber-500/80 ring-1 ring-amber-500' : ''}`}
>
{/* Live Lock Banner if active */}
{isLockedByOther && lockInfo && (
<div className="bg-amber-50 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-500/30 px-2.5 py-1 text-xs rounded-md flex items-center gap-1.5 mb-2 animate-pulse">
<Lock size={12} className="text-amber-600 dark:text-amber-400 shrink-0" />
<span className="truncate">{lockInfo.userName} {t('isEditingGeneral')}</span>
</div>
)}
{/* Blocked / Macet Banner */}
{ticket.isBlocked && (
<div className="bg-rose-50 dark:bg-rose-500/15 text-rose-800 dark:text-rose-300 border border-rose-200 dark:border-rose-500/30 px-2.5 py-1 text-xs rounded-md flex items-start gap-1.5 mb-2">
<AlertOctagon size={13} className="text-rose-600 dark:text-rose-400 shrink-0 mt-0.5" />
<div className="truncate">
<span className="font-semibold text-rose-700 dark:text-rose-400 mr-1">{isRetro ? '🚩 TRAP:' : '🚩 BLOCKED:'}</span>
<span>{ticket.blockedReason || 'Ada kendala'}</span>
</div>
</div>
)}
{/* Stale / Idle Banner */}
{isStale && !ticket.isBlocked && (
<div className="bg-amber-50 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-500/30 px-2.5 py-0.5 text-xs rounded-md flex items-center gap-1.5 mb-2">
<Clock size={12} className="text-amber-600 dark:text-amber-400 shrink-0" />
<span className="truncate text-[11px]"> {staleHoursCount}j tanpa pergerakan</span>
</div>
)}
<div className="flex items-start justify-between gap-2">
{/* Card Title & Click Area */}
<div
onClick={() => {
soundFx.playClick();
onClick();
}}
className="flex-1 cursor-pointer"
>
<h4 className="text-sm font-semibold text-slate-900 dark:text-slate-100 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors leading-snug line-clamp-2">
{ticket.title}
</h4>
{ticket.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 line-clamp-2 mt-1.5 leading-relaxed">
{ticket.description}
</p>
)}
</div>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 cursor-grab active:cursor-grabbing p-1 rounded-md touch-none"
title="Drag card"
>
<GripVertical size={15} />
</button>
</div>
{/* Subtask Progress Bar (if subtasks exist) */}
{totalSubtasks > 0 && (
<div className="mt-3 pt-2 border-t border-slate-100 dark:border-slate-800/80">
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400 mb-1.5">
<span className="flex items-center gap-1.5 font-medium text-[11px]">
<CheckSquare size={12} className="text-indigo-600 dark:text-indigo-400" />
<span>Subtasks</span>
</span>
<span className="text-[11px] font-semibold">{completedSubtasks}/{totalSubtasks}</span>
</div>
<div className="w-full bg-slate-100 dark:bg-slate-950/60 h-1.5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
completedSubtasks === totalSubtasks ? 'bg-emerald-500' : 'bg-indigo-500'
}`}
style={{ width: `${(completedSubtasks / totalSubtasks) * 100}%` }}
></div>
</div>
</div>
)}
{/* Meta Footer: Priority, Due Date, Tags, Assignee */}
<div className="flex flex-wrap items-center justify-between gap-2 mt-3 pt-2.5 border-t border-slate-100 dark:border-slate-800/80">
{/* Priority Badge */}
<span className={`text-[10px] font-medium px-2 py-0.5 rounded-full border ${prioTheme.badge}`}>
{prioTheme.label}
</span>
{/* Due Date Badge */}
{ticket.dueDate && (
<span
className={`text-[10px] font-medium px-2 py-0.5 rounded-full border flex items-center gap-1 ${
dueDateStatus === 'overdue'
? 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/70 dark:text-rose-300 dark:border-rose-500/30'
: dueDateStatus === 'today'
? 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/70 dark:text-amber-300 dark:border-amber-500/30'
: 'bg-slate-100 text-slate-700 border-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:border-slate-700/80'
}`}
>
<Calendar size={10} />
<span>{dueDateStatus === 'overdue' ? `⚠️ ${ticket.dueDate}` : ticket.dueDate}</span>
</span>
)}
{/* Tags */}
<div className="flex flex-wrap items-center gap-1">
{ticket.tags &&
ticket.tags.slice(0, 2).map((tg) => (
<span
key={tg}
className="bg-indigo-50 dark:bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/20 px-2 py-0.5 text-[10px] font-medium rounded-full"
>
#{tg}
</span>
))}
{ticket.tags && ticket.tags.length > 2 && (
<span className="text-[10px] text-slate-400">
+{ticket.tags.length - 2}
</span>
)}
</div>
{/* Assignee */}
{ticket.assignee && (
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700/60 px-2 py-0.5 rounded-full text-[11px] text-slate-700 dark:text-slate-300">
<User size={11} className="text-indigo-600 dark:text-indigo-400" />
<span className="max-w-[70px] truncate font-medium">{ticket.assignee}</span>
</div>
)}
</div>
</div>
);
};
+687
View File
@@ -0,0 +1,687 @@
import React, { useState, useEffect } from 'react';
import { X, Trash2, Lock, Sparkles, Plus, CheckSquare, Square, AlertOctagon, Calendar, History, MoveRight, User, Clock } from 'lucide-react';
import { Ticket, Priority, FieldLock, Subtask, ActivityLog } from '../types/index.js';
import { getSocket } from '../services/socket.js';
import { fetchTicketActivity } from '../services/api.js';
import { soundFx } from '../utils/sound.js';
import { useDebouncedCallback } from '../hooks/useDebounce.js';
import { useTranslation } from '../utils/i18n.js';
interface TicketModalProps {
ticket: Ticket;
boardId: string;
boardKey: string;
activeLocks: FieldLock[];
currentUserId: string;
onClose: () => void;
onUpdateTicket: (ticketId: string, updates: Partial<Ticket>) => void;
onDeleteTicket: (ticketId: string, columnId: string) => void;
}
function formatTimeAgo(isoString: string): string {
try {
const diffMs = Date.now() - new Date(isoString).getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${Math.max(1, diffSec)} detik lalu`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin} menit lalu`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `${diffHours} jam lalu`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} hari lalu`;
} catch {
return isoString;
}
}
export const TicketModal: React.FC<TicketModalProps> = ({
ticket,
boardId,
boardKey,
activeLocks,
currentUserId,
onClose,
onUpdateTicket,
onDeleteTicket,
}) => {
const { t } = useTranslation();
const [title, setTitle] = useState(ticket.title);
const [description, setDescription] = useState(ticket.description);
const [priority, setPriority] = useState<Priority>(ticket.priority);
const [tags, setTags] = useState<string[]>(ticket.tags || []);
const [tagInput, setTagInput] = useState('');
const [subtasks, setSubtasks] = useState<Subtask[]>(ticket.subtasks || []);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [dueDate, setDueDate] = useState(ticket.dueDate || '');
const [isBlocked, setIsBlocked] = useState(ticket.isBlocked || false);
const [blockedReason, setBlockedReason] = useState(ticket.blockedReason || '');
const [assignee, setAssignee] = useState(ticket.assignee || '');
const [isSaving, setIsSaving] = useState(false);
// Activity Logs
const [activityLogs, setActivityLogs] = useState<ActivityLog[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false);
useEffect(() => {
setTitle(ticket.title);
setDescription(ticket.description);
setPriority(ticket.priority);
setTags(ticket.tags || []);
setSubtasks(ticket.subtasks || []);
setDueDate(ticket.dueDate || '');
setIsBlocked(ticket.isBlocked || false);
setBlockedReason(ticket.blockedReason || '');
setAssignee(ticket.assignee || '');
}, [ticket]);
// Load ticket activity logs from DB
useEffect(() => {
if (boardId && boardKey && ticket.id) {
setLoadingLogs(true);
fetchTicketActivity(boardId, ticket.id, boardKey)
.then((logs) => setActivityLogs(logs))
.catch(() => {})
.finally(() => setLoadingLogs(false));
}
const socket = getSocket();
if (socket) {
const handleActivityLogged = (payload: { activity: ActivityLog }) => {
if (payload.activity && payload.activity.ticketId === ticket.id) {
setActivityLogs((prev) => [payload.activity, ...prev]);
}
};
socket.on('activity_logged', handleActivityLogged);
return () => {
socket.off('activity_logged', handleActivityLogged);
};
}
}, [boardId, boardKey, ticket.id]);
// Check if title or description are locked by another user
const titleLock = activeLocks.find(
(l) => l.ticketId === ticket.id && l.field === 'title' && l.userId !== currentUserId
);
const descLock = activeLocks.find(
(l) => l.ticketId === ticket.id && l.field === 'description' && l.userId !== currentUserId
);
// Debounced save for Title (500ms)
const [debouncedSaveTitle] = useDebouncedCallback((val: string) => {
setIsSaving(true);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { title: val },
});
}
onUpdateTicket(ticket.id, { title: val });
setTimeout(() => setIsSaving(false), 300);
}, 500);
// Debounced save for Description (600ms)
const [debouncedSaveDesc] = useDebouncedCallback((val: string) => {
setIsSaving(true);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { description: val },
});
}
onUpdateTicket(ticket.id, { description: val });
setTimeout(() => setIsSaving(false), 300);
}, 600);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setTitle(val);
debouncedSaveTitle(val);
};
const handleTitleFocus = () => {
const socket = getSocket();
if (socket) {
socket.emit('lock_field', { ticketId: ticket.id, field: 'title' });
}
};
const handleTitleBlur = () => {
const socket = getSocket();
if (socket) {
socket.emit('unlock_field', { ticketId: ticket.id, field: 'title' });
}
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setDescription(val);
debouncedSaveDesc(val);
};
const handleDescFocus = () => {
const socket = getSocket();
if (socket) {
socket.emit('lock_field', { ticketId: ticket.id, field: 'description' });
}
};
const handleDescBlur = () => {
const socket = getSocket();
if (socket) {
socket.emit('unlock_field', { ticketId: ticket.id, field: 'description' });
}
};
// Priority Change
const handlePriorityChange = (newPrio: Priority) => {
soundFx.playClick();
setPriority(newPrio);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { priority: newPrio },
});
}
onUpdateTicket(ticket.id, { priority: newPrio });
};
// Subtask Handlers
const handleAddSubtask = (e: React.FormEvent) => {
e.preventDefault();
if (!newSubtaskTitle.trim()) return;
soundFx.playClick();
const newSubtask: Subtask = {
id: 'st_' + Math.random().toString(36).substring(2, 9),
title: newSubtaskTitle.trim(),
completed: false,
};
const updated = [...subtasks, newSubtask];
setSubtasks(updated);
setNewSubtaskTitle('');
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { subtasks: updated },
});
}
onUpdateTicket(ticket.id, { subtasks: updated });
};
const handleToggleSubtask = (subtaskId: string) => {
soundFx.playClick();
const updated = subtasks.map((st) =>
st.id === subtaskId ? { ...st, completed: !st.completed } : st
);
setSubtasks(updated);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { subtasks: updated },
});
}
onUpdateTicket(ticket.id, { subtasks: updated });
};
const handleDeleteSubtask = (subtaskId: string) => {
soundFx.playClick();
const updated = subtasks.filter((st) => st.id !== subtaskId);
setSubtasks(updated);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { subtasks: updated },
});
}
onUpdateTicket(ticket.id, { subtasks: updated });
};
// Due Date Change
const handleDueDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setDueDate(val);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { dueDate: val || null },
});
}
onUpdateTicket(ticket.id, { dueDate: val || null });
};
// Blocked Status Change
const handleToggleBlocked = () => {
soundFx.playLock();
const nextBlocked = !isBlocked;
setIsBlocked(nextBlocked);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { isBlocked: nextBlocked, blockedReason: nextBlocked ? blockedReason : null },
});
}
onUpdateTicket(ticket.id, { isBlocked: nextBlocked, blockedReason: nextBlocked ? blockedReason : null });
};
const handleBlockedReasonBlur = () => {
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { blockedReason: blockedReason.trim() || null },
});
}
onUpdateTicket(ticket.id, { blockedReason: blockedReason.trim() || null });
};
// Tag Handlers
const handleAddTag = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && tagInput.trim()) {
e.preventDefault();
soundFx.playClick();
const newTag = tagInput.trim().replace(/^#/, '');
if (!tags.includes(newTag)) {
const updated = [...tags, newTag];
setTags(updated);
setTagInput('');
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { tags: updated },
});
}
onUpdateTicket(ticket.id, { tags: updated });
}
}
};
const handleRemoveTag = (tagToRemove: string) => {
soundFx.playClick();
const updated = tags.filter((t) => t !== tagToRemove);
setTags(updated);
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { tags: updated },
});
}
onUpdateTicket(ticket.id, { tags: updated });
};
const handleAssigneeBlur = () => {
const socket = getSocket();
if (socket) {
socket.emit('update_ticket', {
ticketId: ticket.id,
updates: { assignee: assignee.trim() || null },
});
}
onUpdateTicket(ticket.id, { assignee: assignee.trim() || null });
};
const handleDelete = () => {
soundFx.playClick();
if (confirm(t('deleteTaskConfirm'))) {
const socket = getSocket();
if (socket) {
socket.emit('delete_ticket', {
ticketId: ticket.id,
columnId: ticket.columnId,
});
}
onDeleteTicket(ticket.id, ticket.columnId);
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-slate-950/70 backdrop-blur-md animate-fade-in">
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl max-w-2xl w-full p-4 sm:p-7 relative max-h-[92vh] overflow-y-auto text-slate-900 dark:text-slate-100">
{/* Close Button */}
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="absolute top-4 right-4 sm:top-5 sm:right-5 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 p-1 rounded-md transition-colors"
>
<X size={20} />
</button>
{/* Live Auto-save indicator */}
<div className="flex items-center gap-2 mb-4">
<span className="text-xs font-medium bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-500/20 px-2.5 py-1 rounded-full flex items-center gap-1.5">
<Sparkles size={13} className="text-emerald-600 dark:text-emerald-400" />
<span>{isSaving ? t('autoSaving') : t('synced')}</span>
</span>
</div>
{/* Title Input with Field Lock */}
<div className="mb-4">
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
{t('taskTitle')}
</label>
{titleLock && (
<div className="bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300 text-xs px-3 py-1.5 mb-2 rounded-lg flex items-center gap-2 animate-pulse">
<Lock size={14} className="shrink-0 text-amber-600 dark:text-amber-400" />
<span>{titleLock.userName} {t('isTypingTitle')}</span>
</div>
)}
<input
type="text"
value={title}
disabled={!!titleLock}
onChange={handleTitleChange}
onFocus={handleTitleFocus}
onBlur={handleTitleBlur}
placeholder="Judul tugas..."
className={`w-full bg-slate-50 dark:bg-slate-950/70 border ${
titleLock ? 'border-amber-500/40 opacity-60 cursor-not-allowed' : 'border-slate-300 dark:border-slate-800 focus:border-indigo-500'
} text-slate-900 dark:text-slate-100 text-sm font-semibold p-3 rounded-lg outline-none transition-colors`}
/>
</div>
{/* Description Textarea with Field Lock */}
<div className="mb-5">
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
{t('taskDesc')}
</label>
{descLock && (
<div className="bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-300 text-xs px-3 py-1.5 mb-2 rounded-lg flex items-center gap-2 animate-pulse">
<Lock size={14} className="shrink-0 text-amber-600 dark:text-amber-400" />
<span>{descLock.userName} {t('isTypingDesc')}</span>
</div>
)}
<textarea
rows={4}
value={description}
disabled={!!descLock}
onChange={handleDescriptionChange}
onFocus={handleDescFocus}
onBlur={handleDescBlur}
placeholder={t('taskDescPlaceholder')}
className={`w-full bg-slate-50 dark:bg-slate-950/70 border ${
descLock ? 'border-amber-500/40 opacity-60 cursor-not-allowed' : 'border-slate-300 dark:border-slate-800 focus:border-indigo-500'
} text-slate-800 dark:text-slate-200 text-xs p-3 rounded-lg outline-none leading-relaxed transition-colors`}
/>
</div>
{/* Priority Selector */}
<div className="mb-5">
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-2">
{t('priorityLabel')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{[
{ id: 'LOW', label: `🟢 ${t('prioLow')}`, activeClass: 'bg-emerald-50 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300 border-emerald-500 font-bold' },
{ id: 'MEDIUM', label: `🔵 ${t('prioMedium')}`, activeClass: 'bg-blue-50 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500 font-bold' },
{ id: 'HIGH', label: `🟡 ${t('prioHigh')}`, activeClass: 'bg-amber-50 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500 font-bold' },
{ id: 'URGENT', label: `🔴 ${t('prioUrgent')}`, activeClass: 'bg-rose-50 dark:bg-rose-500/20 text-rose-700 dark:text-rose-300 border-rose-500 font-bold' },
].map((p) => (
<button
key={p.id}
type="button"
onClick={() => handlePriorityChange(p.id as Priority)}
className={`py-2 px-3 text-xs font-medium rounded-lg border transition-all ${
priority === p.id
? p.activeClass
: 'bg-slate-50 dark:bg-slate-950/60 border-slate-300 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
{p.label}
</button>
))}
</div>
</div>
{/* Checklist / Subtasks Section */}
<div className="mb-5 bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<label className="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<CheckSquare size={15} className="text-indigo-600 dark:text-emerald-400" />
<span>{t('subtasksLabel')} ({subtasks.filter((s) => s.completed).length}/{subtasks.length})</span>
</label>
</div>
{/* Subtask list */}
<div className="space-y-2 mb-3">
{subtasks.map((st) => (
<div
key={st.id}
className="flex items-center justify-between gap-2 bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800 p-2.5 rounded-lg group"
>
<div
onClick={() => handleToggleSubtask(st.id)}
className="flex items-center gap-2.5 cursor-pointer flex-1"
>
<button type="button" className="text-indigo-600 dark:text-emerald-400">
{st.completed ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<span
className={`text-xs ${
st.completed
? 'line-through text-slate-400 dark:text-slate-500'
: 'text-slate-800 dark:text-slate-200'
}`}
>
{st.title}
</span>
</div>
<button
type="button"
onClick={() => handleDeleteSubtask(st.id)}
className="text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 p-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</div>
))}
</div>
{/* Add Subtask input */}
<form onSubmit={handleAddSubtask} className="flex items-center gap-2">
<input
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder={t('addSubtaskPlaceholder')}
className="flex-1 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 p-2 text-xs text-slate-900 dark:text-slate-100 rounded-lg outline-none focus:border-indigo-500"
/>
<button
type="submit"
className="btn-modern-primary py-2 px-3 text-xs flex items-center gap-1 font-semibold"
>
<Plus size={14} />
<span>{t('add')}</span>
</button>
</form>
</div>
{/* Due Date & Blocked Status Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
{/* Due Date */}
<div className="bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 p-3.5 rounded-xl">
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 flex items-center gap-1.5">
<Calendar size={14} className="text-indigo-600 dark:text-emerald-400" />
<span>{t('dueDateLabel')}</span>
</label>
<input
type="date"
value={dueDate}
onChange={handleDueDateChange}
className="w-full bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-800 p-2 text-xs text-slate-900 dark:text-slate-100 rounded-lg outline-none"
/>
</div>
{/* Blocked Status */}
<div className="bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 p-3.5 rounded-xl">
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5 flex items-center gap-1.5">
<AlertOctagon size={14} className="text-rose-600 dark:text-rose-400" />
<span>{t('blockedLabel')}</span>
</label>
<button
type="button"
onClick={handleToggleBlocked}
className={`w-full py-2 px-3 text-xs font-semibold rounded-lg border flex items-center justify-center gap-2 transition-all ${
isBlocked
? 'bg-rose-50 dark:bg-rose-500/20 text-rose-700 dark:text-rose-300 border-rose-500'
: 'bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
<AlertOctagon size={14} />
<span>{isBlocked ? '🚩 Status: BLOCKED' : 'Normal (Tidak Macet)'}</span>
</button>
{isBlocked && (
<input
type="text"
value={blockedReason}
onChange={(e) => setBlockedReason(e.target.value)}
onBlur={handleBlockedReasonBlur}
placeholder={t('blockedReasonPlaceholder')}
className="w-full mt-2 bg-white dark:bg-slate-900 border border-rose-400 dark:border-rose-500/50 p-2 text-xs text-rose-700 dark:text-rose-300 rounded-lg outline-none"
/>
)}
</div>
</div>
{/* Tags & Assignee row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
{/* Tags */}
<div>
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
{t('tagsLabel')}
</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tg) => (
<span
key={tg}
className="bg-indigo-50 dark:bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-500/30 px-2.5 py-0.5 text-xs font-medium rounded-full flex items-center gap-1.5"
>
#{tg}
<button
onClick={() => handleRemoveTag(tg)}
className="text-indigo-600 dark:text-indigo-400 hover:text-indigo-900 dark:hover:text-indigo-200"
>
×
</button>
</span>
))}
</div>
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleAddTag}
placeholder={t('tagsPlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 text-xs p-2 text-indigo-700 dark:text-indigo-300 rounded-lg outline-none focus:border-indigo-500"
/>
</div>
{/* Assignee */}
<div>
<label className="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">
{t('assigneeLabel')}
</label>
<input
type="text"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
onBlur={handleAssigneeBlur}
placeholder={t('assigneePlaceholder')}
className="w-full bg-slate-50 dark:bg-slate-950/70 border border-slate-300 dark:border-slate-800 text-xs p-2 text-slate-900 dark:text-slate-200 rounded-lg outline-none focus:border-indigo-500"
/>
</div>
</div>
{/* Riwayat Perpindahan Kartu (Database Movement History) */}
<div className="mb-6 bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800 rounded-xl p-3.5 sm:p-4">
<div className="flex items-center justify-between mb-2.5">
<label className="text-xs font-bold text-slate-800 dark:text-slate-200 flex items-center gap-2">
<History size={14} className="text-indigo-600 dark:text-indigo-400" />
<span>{t('cardMovementHistory')}</span>
</label>
<span className="text-[10px] text-slate-400 font-mono">
{activityLogs.length} catatan
</span>
</div>
<div className="space-y-2 max-h-44 overflow-y-auto pr-1">
{loadingLogs ? (
<p className="text-[11px] text-slate-400 text-center py-2">Memuat riwayat...</p>
) : activityLogs.length === 0 ? (
<p className="text-[11px] text-slate-400 text-center py-2">Belum ada pergerakan kolom yang tercatat</p>
) : (
activityLogs.map((log) => (
<div
key={log.id}
className="bg-white dark:bg-slate-900/80 border border-slate-200 dark:border-slate-800/80 p-2.5 rounded-lg text-xs flex items-start justify-between gap-2"
>
<div className="space-y-0.5 flex-1 min-w-0">
{log.action === 'MOVED' && log.fromColumnTitle && log.toColumnTitle ? (
<p className="text-[11px] text-slate-700 dark:text-slate-300 flex items-center gap-1.5 flex-wrap">
<span className="font-semibold">{log.fromColumnTitle}</span>
<MoveRight size={11} className="text-slate-400" />
<span className="font-semibold text-indigo-600 dark:text-indigo-400">{log.toColumnTitle}</span>
</p>
) : (
<p className="text-[11px] text-slate-700 dark:text-slate-300 font-medium">
{log.details || log.action}
</p>
)}
<div className="flex items-center gap-2 text-[10px] text-slate-400">
<span className="flex items-center gap-0.5">
<User size={10} />
<span>{log.userName}</span>
</span>
<span></span>
<span className="flex items-center gap-0.5">
<Clock size={10} />
<span>{formatTimeAgo(log.createdAt)}</span>
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between pt-4 border-t border-slate-200 dark:border-slate-800">
<button
onClick={handleDelete}
className="btn-modern-danger flex items-center gap-1.5 text-xs py-2 px-3.5"
>
<Trash2 size={14} />
<span>{t('deleteTask')}</span>
</button>
<button
onClick={() => {
soundFx.playClick();
onClose();
}}
className="btn-modern-primary py-2 px-6 font-bold"
>
{t('save')}
</button>
</div>
</div>
</div>
);
};
+57
View File
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState, useCallback } from 'react';
/**
* Hook for debouncing a value with a configurable delay (default 500ms).
*/
export function useDebouncedValue<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
/**
* Hook to execute a debounced callback after inactivity.
*/
export function useDebouncedCallback<T extends (...args: any[]) => any>(
callback: T,
delay: number = 500
): [(...args: Parameters<T>) => void, () => void] {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const callbackRef = useRef(callback);
callbackRef.current = callback;
const debouncedFn = useCallback(
(...args: Parameters<T>) => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
callbackRef.current(...args);
}, delay);
},
[delay]
);
const cancel = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => {
return cancel;
}, [cancel]);
return [debouncedFn, cancel];
}
+253
View File
@@ -0,0 +1,253 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Default Modern Sans-serif Typography */
html:not(.retro-mode) body,
html:not(.retro-mode) button,
html:not(.retro-mode) input,
html:not(.retro-mode) textarea,
html:not(.retro-mode) select {
font-family: 'Inter', 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
letter-spacing: -0.01em;
}
/* Retro Mode Typography & Rendering (100% Identical Layout Dimensions) */
html.retro-mode body,
html.retro-mode button,
html.retro-mode input,
html.retro-mode textarea,
html.retro-mode select,
html.retro-mode * {
font-family: 'Silkscreen', 'Press Start 2P', monospace !important;
image-rendering: pixelated;
}
/* Modern Light Theme Defaults */
body {
background-color: #f8fafc;
color: #0f172a;
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Modern Dark Theme */
html.dark body {
background-color: #080c18;
color: #f1f5f9;
}
/* Retro Dark Theme Body */
html.retro-mode.dark body {
background-color: #12101b !important;
color: #f1f5f9 !important;
}
/* Retro Light Theme Body (Vintage Parchment / Gameboy Paper) */
html.retro-mode:not(.dark) body {
background-color: #f4f3ea !important;
color: #0f172a !important;
}
/* Modern Scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #6366f1;
}
html.dark ::-webkit-scrollbar-thumb {
background: #334155;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #6366f1;
}
/* Retro Mode Scrollbar */
html.retro-mode ::-webkit-scrollbar {
width: 10px;
height: 10px;
}
html.retro-mode.dark ::-webkit-scrollbar-track {
background: #111118;
border-left: 2px solid #000;
}
html.retro-mode:not(.dark) ::-webkit-scrollbar-track {
background: #eae8dc;
border-left: 2px solid #000;
}
html.retro-mode ::-webkit-scrollbar-thumb {
background: #374151;
border: 2px solid #000;
border-radius: 0px;
}
html.retro-mode ::-webkit-scrollbar-thumb:hover {
background: #f59e0b;
}
}
/* ========================================================= */
/* MODERN SAAS UI PALETTE & CLASSES */
/* ========================================================= */
.card-kanban {
@apply bg-white dark:bg-slate-900/90 border border-slate-200/90 dark:border-slate-800/80 rounded-2xl shadow-sm dark:shadow-subtle transition-all duration-200;
}
.column-kanban {
@apply bg-slate-100/90 dark:bg-[#0c1222]/80 border border-slate-200 dark:border-slate-800/80 rounded-2xl transition-all duration-200;
}
.ticket-kanban {
@apply bg-white dark:bg-[#11192e] border border-slate-200/90 dark:border-slate-800/90 text-slate-900 dark:text-slate-100 rounded-xl shadow-sm dark:shadow-subtle hover:shadow-md dark:hover:shadow-modern hover:border-indigo-400 dark:hover:border-indigo-500/40 transition-all duration-150;
}
/* Modern Buttons */
.btn-modern {
@apply relative text-xs font-semibold px-4 py-2 rounded-lg transition-all duration-150 flex items-center justify-center gap-2 select-none disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98];
}
.btn-modern-primary {
@apply btn-modern bg-indigo-600 hover:bg-indigo-500 text-white font-semibold shadow-sm shadow-indigo-600/25 hover:shadow-indigo-600/40;
}
.btn-modern-secondary {
@apply btn-modern bg-slate-100 hover:bg-slate-200 text-slate-800 border border-slate-300 dark:bg-slate-800/90 dark:hover:bg-slate-700/90 dark:text-slate-200 dark:border-slate-700/70;
}
.btn-modern-danger {
@apply btn-modern bg-rose-600 hover:bg-rose-500 text-white shadow-sm shadow-rose-600/20;
}
.btn-modern-gold {
@apply btn-modern bg-amber-500 hover:bg-amber-400 text-slate-950 font-bold shadow-sm shadow-amber-500/20;
}
/* ========================================================= */
/* 8-BIT RETRO PALETTE OVERRIDES: DARK MODE */
/* ========================================================= */
html.retro-mode.dark .card-kanban {
border-radius: 0px !important;
border: 4px solid #000000 !important;
box-shadow: 6px 6px 0px 0px #000000 !important;
background-color: #1a1626 !important;
color: #f1f5f9 !important;
}
html.retro-mode.dark .column-kanban {
border-radius: 0px !important;
border: 4px solid #000000 !important;
box-shadow: 4px 4px 0px 0px #000000 !important;
background-color: #151122 !important;
}
html.retro-mode.dark .ticket-kanban {
border-radius: 0px !important;
border: 2px solid #000000 !important;
box-shadow: 2px 2px 0px 0px #000000 !important;
background-color: #221d33 !important;
color: #ffffff !important;
}
html.retro-mode.dark input,
html.retro-mode.dark textarea,
html.retro-mode.dark select {
border-radius: 0px !important;
border: 2px solid #000000 !important;
box-shadow: inset 2px 2px 0px 0px rgba(0,0,0,0.3) !important;
background-color: #161224 !important;
color: #ffffff !important;
}
html.retro-mode.dark .btn-modern-secondary {
background-color: #3b354d !important;
color: #ffffff !important;
}
/* ========================================================= */
/* 8-BIT RETRO PALETTE OVERRIDES: LIGHT MODE (PAPER RPG) */
/* ========================================================= */
html.retro-mode:not(.dark) .card-kanban {
border-radius: 0px !important;
border: 4px solid #000000 !important;
box-shadow: 6px 6px 0px 0px #000000 !important;
background-color: #ffffff !important;
color: #0f172a !important;
}
html.retro-mode:not(.dark) .column-kanban {
border-radius: 0px !important;
border: 4px solid #000000 !important;
box-shadow: 4px 4px 0px 0px #000000 !important;
background-color: #eae8dc !important;
color: #0f172a !important;
}
html.retro-mode:not(.dark) .ticket-kanban {
border-radius: 0px !important;
border: 2px solid #000000 !important;
box-shadow: 2px 2px 0px 0px #000000 !important;
background-color: #ffffff !important;
color: #0f172a !important;
}
html.retro-mode:not(.dark) input,
html.retro-mode:not(.dark) textarea,
html.retro-mode:not(.dark) select {
border-radius: 0px !important;
border: 2px solid #000000 !important;
box-shadow: inset 2px 2px 0px 0px rgba(0,0,0,0.1) !important;
background-color: #ffffff !important;
color: #0f172a !important;
}
html.retro-mode:not(.dark) .btn-modern-secondary {
background-color: #e2e0d2 !important;
color: #0f172a !important;
border: 2px solid #000000 !important;
}
/* Common 8-Bit Button Overrides */
html.retro-mode .btn-modern {
border-radius: 0px !important;
border: 2px solid #000000 !important;
box-shadow: 3px 3px 0px 0px #000000 !important;
text-transform: uppercase !important;
}
html.retro-mode .btn-modern:active {
transform: translate(2px, 2px) !important;
box-shadow: 0px 0px 0px 0px #000000 !important;
}
html.retro-mode .btn-modern-primary {
background-color: #10b981 !important;
color: #000000 !important;
box-shadow: 3px 3px 0px 0px #000000 !important;
}
html.retro-mode .btn-modern-danger {
background-color: #e11d48 !important;
color: #ffffff !important;
}
html.retro-mode .btn-modern-gold {
background-color: #f59e0b !important;
color: #000000 !important;
}
html.retro-mode .ticket-kanban:hover {
transform: translateY(-2px) !important;
box-shadow: 4px 4px 0px 0px #000000 !important;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.js';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+112
View File
@@ -0,0 +1,112 @@
import { Board, BoardCreationResponse, ActivityLog } from '../types/index.js';
export async function createBoard(title: string): Promise<BoardCreationResponse> {
const res = await fetch('/api/boards', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to forge board');
}
return res.json();
}
export async function fetchBoard(boardId: string, key: string): Promise<Board> {
const res = await fetch(`/api/boards/${boardId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to enter board');
}
return res.json();
}
export async function fetchBoardActivity(boardId: string, key: string, limit: number = 50): Promise<ActivityLog[]> {
const res = await fetch(`/api/boards/${boardId}/activity?limit=${limit}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to fetch board activity');
}
return res.json();
}
export async function fetchTicketActivity(boardId: string, ticketId: string, key: string): Promise<ActivityLog[]> {
const res = await fetch(`/api/boards/${boardId}/tickets/${ticketId}/activity`, {
method: 'GET',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to fetch ticket activity');
}
return res.json();
}
export async function rotateMemberKey(boardId: string, adminKey: string): Promise<{ newMemberKey: string; memberUrlFragment: string }> {
const res = await fetch(`/api/boards/${boardId}/rotate-member-key`, {
method: 'POST',
headers: {
Authorization: `Bearer ${adminKey}`,
},
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to rotate member key');
}
return res.json();
}
export async function updateBoardTitle(boardId: string, title: string, adminKey: string): Promise<void> {
const res = await fetch(`/api/boards/${boardId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminKey}`,
},
body: JSON.stringify({ title }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to update board title');
}
}
export async function deleteBoard(boardId: string, adminKey: string): Promise<void> {
const res = await fetch(`/api/boards/${boardId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${adminKey}`,
},
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.message || errorData.error || 'Failed to delete board');
}
}
+33
View File
@@ -0,0 +1,33 @@
import { io, Socket } from 'socket.io-client';
let socket: Socket | null = null;
export function initSocket(boardId: string, key: string, userName: string, userId: string): Socket {
if (socket) {
socket.disconnect();
}
// Connect to current host or proxy
socket = io({
auth: {
boardId,
key,
userName,
userId,
},
transports: ['websocket', 'polling'],
});
return socket;
}
export function getSocket(): Socket | null {
return socket;
}
export function disconnectSocket(): void {
if (socket) {
socket.disconnect();
socket = null;
}
}
+98
View File
@@ -0,0 +1,98 @@
export type Role = 'admin' | 'member';
export type Priority = 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
export interface Subtask {
id: string;
title: string;
completed: boolean;
}
export interface ActivityLog {
id: string;
boardId: string;
ticketId?: string | null;
ticketTitle: string;
action: 'MOVED' | 'CREATED' | 'BLOCKED' | 'UNBLOCKED' | 'DELETED';
fromColumnTitle?: string | null;
toColumnTitle?: string | null;
userName: string;
details?: string | null;
createdAt: string;
}
export interface Ticket {
id: string;
columnId: string;
title: string;
description: string;
order: number;
priority: Priority;
tags: string[];
subtasks: Subtask[];
dueDate?: string | null;
isBlocked?: boolean;
blockedReason?: string | null;
assignee?: string | null;
movedAt: string;
createdAt: string;
updatedAt: string;
}
export interface Column {
id: string;
boardId: string;
title: string;
order: number;
maxWipLimit: number;
autoStaleHours: number;
tickets: Ticket[];
createdAt: string;
updatedAt: string;
}
export interface Board {
id: string;
title: string;
role: Role;
autoMoveStale: boolean;
columns: Column[];
createdAt: string;
updatedAt: string;
}
export interface FieldLock {
ticketId: string;
field: 'title' | 'description';
socketId?: string;
userId: string;
userName: string;
color?: string;
lockedAt: number;
}
export interface PresenceUser {
socketId: string;
userId: string;
userName: string;
color: string;
role: Role;
}
export interface SavedBoard {
id: string;
title: string;
role: Role;
url: string;
lastVisited: number;
}
export interface BoardCreationResponse {
id: string;
title: string;
adminKey: string;
memberKey: string;
adminUrlFragment: string;
memberUrlFragment: string;
createdAt: string;
}
+417
View File
@@ -0,0 +1,417 @@
import { getRetroMode } from './mode.js';
export type Language = 'id' | 'en';
export const modernTranslations = {
id: {
// Branding
appName: 'TautKan',
appTagline: 'Satu tautan untuk semua urusan.',
appBadge: 'AGILE KANBAN',
// Controls
lightMode: 'Mode Terang',
darkMode: 'Mode Gelap',
retroModeOn: '🕹️ 8-Bit Mode',
retroModeOff: '✨ Modern Mode',
muteAudio: 'Matikan Suara Audio',
unmuteAudio: 'Nyalakan Suara Audio',
changeName: 'Ubah Nama Pengguna',
// Roles
superadmin: 'Superadmin',
member: 'Anggota',
roleSuperadminDesc: 'Pemilik Papan (Akses Penuh)',
roleMemberDesc: 'Anggota Tim (Kolaborator)',
// Hero Section
heroBadge: 'TANPA PASSWORD • TANPA EMAIL • 100% REAL-TIME',
heroTitle: 'Papan Kanban Real-Time Modern',
heroSubtitle: 'Kolaborasi manajemen proyek instan berbasis URL Fragment Hash. Tanpa registrasi akun, aman, cepat, dan terhubung secara langsung antar anggota tim.',
// Create Board
createBoardHeader: 'Buat Papan Kanban Baru',
boardTitleLabel: 'Judul Proyek / Papan',
boardTitlePlaceholder: 'Contoh: Pengembangan Fitur Sprint 1...',
createBoardBtn: 'Buat Papan Sekarang',
creatingBoardBtn: 'Sedang Membuat Papan...',
rateLimitNote: 'Batas: 5 papan per jam per IP',
rbacNote: 'Otorisasi Aman URL Fragment',
// Saved Boards
savedBoardsTitle: 'Papan Tersimpan',
savedBoardsSub: 'Riwayat tersimpan di browser Anda',
noSavedBoards: 'Belum ada papan yang tersimpan.',
removeSaved: 'Hapus dari daftar',
// Board View Header
onlineUsers: 'Pengguna Online',
shareInviteBtn: 'Bagikan & Undang',
rotateKeyBtn: 'Putar Kunci Anggota',
rotateKeyTooltip: 'Regenerasi tautan anggota untuk mencabut akses lama',
deleteBoardTooltip: 'Hapus papan ini secara permanen',
boardSettings: 'Pengaturan Papan',
autoMoveStaleSetting: 'Otomatis kembalikan kartu macet (> batas jam) ke Backlog',
backToHome: 'Kembali ke Beranda',
connectingToRealm: 'Menghubungkan ke papan...',
accessForbidden: 'Akses Ditolak',
accessForbiddenDesc: 'Kunci otorisasi tidak valid atau sudah tidak berlaku.',
reEnterKey: 'Masukkan Kunci Kembali',
boardLogsBtn: 'Riwayat Log',
activityLogTitle: 'Riwayat Perpindahan & Log Aktivitas',
noActivityLogs: 'Belum ada riwayat pergerakan kartu yang tercatat.',
// Columns & WIP
addColumn: 'Tambah Kolom',
columnTitlePlaceholder: 'Nama kolom (misal: QA Testing)...',
addNewTask: 'Tambah Tugas',
taskTitlePlaceholder: 'Tulis judul tugas...',
enterToAdd: 'Tekan Enter ↵ untuk simpan',
cancel: 'Batal',
add: 'Tambah',
save: 'Simpan',
noTasksInCol: 'Belum ada tugas di kolom ini',
dragColTooltip: 'Geser untuk mengatur urutan kolom',
deleteColConfirm: 'Hapus kolom ini beserta semua tugas di dalamnya?',
editColSettings: 'Pengaturan Kolom & WIP Limit',
wipLimitLabel: 'WIP Limit (Maksimal Kartu):',
wipUnlimited: '0 = Tanpa Batas',
autoStaleHoursLabel: 'Batas Macet / Stale (Jam Inaktivitas):',
wipLimitReached: 'WIP Limit Tercapai',
wipWarningModal: 'Kolom ini telah mencapai batas kapasitas maksimum kartu (WIP Limit). Selesaikan tugas yang ada terlebih dahulu!',
// Ticket Modal / Editor
taskTitle: 'Judul Tugas',
taskDesc: 'Deskripsi Tugas / Spesifikasi',
taskDescPlaceholder: 'Tuliskan rincian tugas, kriteria penerimaan, atau catatan penting...',
priorityLabel: 'Prioritas / Urgensi',
tagsLabel: 'Label / Tags (Tekan Enter)',
tagsPlaceholder: 'Contoh: FRONTEND, BUG, BACKEND...',
assigneeLabel: 'Penanggung Jawab (Assignee)',
assigneePlaceholder: 'Contoh: Budi Santoso',
dueDateLabel: 'Tenggat Waktu (Due Date)',
subtasksLabel: 'Checklist Sub-tugas',
addSubtaskPlaceholder: 'Tambah sub-tugas baru... (Enter)',
blockedLabel: 'Status Macet / Tertahan (Blocked)',
markBlocked: 'Tandai Sebagai Macet (Blocked)',
blockedReasonLabel: 'Alasan Kendala / Blocker:',
blockedReasonPlaceholder: 'Contoh: Menunggu API pihak ketiga selesai / Butuh approval...',
staleIndicator: 'Kartu Macet (Stale)',
staleSince: 'Tidak ada pergerakan selama',
overdue: 'Terlambat',
dueToday: 'Hari Ini',
autoSaving: 'Menyimpan otomatis...',
synced: 'Tersinkronisasi',
isTypingTitle: 'sedang mengetik judul tugas...',
isTypingDesc: 'sedang mengetik deskripsi...',
isEditingGeneral: 'sedang mengedit tugas ini...',
deleteTask: 'Hapus Tugas',
close: 'Tutup',
deleteTaskConfirm: 'Apakah Anda yakin ingin menghapus tugas ini?',
cardMovementHistory: 'Riwayat Perpindahan Kartu (Database Log)',
// Priorities
prioLow: 'Rendah',
prioMedium: 'Sedang',
prioHigh: 'Tinggi',
prioUrgent: 'Mendesak',
// Success Share Modal
boardCreatedTitle: 'Papan TautKan Berhasil Dibuat!',
boardName: 'Nama Papan',
adminLinkTitle: 'Tautan Admin (Superadmin)',
adminLinkWarning: 'JANGAN BAGIKAN! HANYA UNTUK ANDA',
adminLinkSavedNote: 'Tautan ini tersimpan di browser Anda. Bookmark tautan ini untuk mengelola papan.',
memberLinkTitle: 'Tautan Undangan Anggota Tim',
memberLinkBadge: 'UNTUK REKAN TIM',
copied: 'Tersalin!',
copy: 'Salin',
shareWhatsApp: 'Bagikan ke WhatsApp / Aplikasi',
enterBoardBtn: 'Buka Papan Kanban',
// Key Rotation Modal
keyRotationTitle: 'Rotasi Kunci Anggota',
keyRotationSubtitle: 'Buat Ulang Tautan Undangan Anggota Tim',
keyRotationWarningTitle: 'Peringatan: Menonaktifkan Tautan Lama!',
keyRotationWarningDesc: 'Membuat kunci anggota baru akan secara instan mencabut izin seluruh anggota yang menggunakan tautan lama dan memutuskan sesi mereka secara real-time.',
regenerateBtn: 'Buat Ulang Tautan',
rotatingBtn: 'Memproses...',
newLinkActiveTitle: 'Tautan Baru Berhasil Diaktifkan!',
newLinkActiveDesc: 'Tautan anggota lama telah dinonaktifkan. Bagikan tautan baru ini kepada rekan tim Anda:',
done: 'Selesai',
// Key Prompt Modal
keyPromptTitle: 'Kunci Otorisasi Diperlukan',
keyPromptDesc: 'Papan ini terlindungi oleh URL Fragment Key. Silakan tempel tautan lengkap (#admin=... atau #member=...) atau kunci rahasia Anda di bawah ini.',
keyPromptPlaceholder: 'Tempel tautan #admin=... atau #member=... atau kunci Anda',
unlockBoardBtn: 'Buka Papan',
keyPromptError: 'Silakan masukkan kunci atau tautan undangan',
// Revoked Alert
sessionRevokedMsg: 'Superadmin telah membuat ulang tautan anggota. Sesi Anda telah dihentikan.',
movementFailed: 'Gagal memindahkan kartu:',
footerText: 'TautKan • Satu tautan untuk semua urusan • Dual-Code URL Fragment Real-Time Agile',
},
en: {
// Branding
appName: 'TautKan',
appTagline: 'One link to manage it all.',
appBadge: 'AGILE KANBAN',
// Controls
lightMode: 'Light Mode',
darkMode: 'Dark Mode',
retroModeOn: '🕹️ 8-Bit Mode',
retroModeOff: '✨ Modern Mode',
muteAudio: 'Mute Audio',
unmuteAudio: 'Unmute Audio',
changeName: 'Change User Name',
// Roles
superadmin: 'Superadmin',
member: 'Member',
roleSuperadminDesc: 'Board Owner (Full Access)',
roleMemberDesc: 'Team Member (Collaborator)',
// Hero Section
heroBadge: 'ZERO PASSWORDS • ZERO EMAILS • 100% REAL-TIME',
heroTitle: 'Modern Real-Time Agile Kanban',
heroSubtitle: 'Instant project collaboration powered by URL Fragment Hashes. Zero sign-up required, ultra-secure, fast, and connected in real time.',
// Create Board
createBoardHeader: 'Create a New Kanban Board',
boardTitleLabel: 'Project / Board Title',
boardTitlePlaceholder: 'e.g. Sprint 1 - Core Feature Development...',
createBoardBtn: 'Create Board Now',
creatingBoardBtn: 'Creating Board...',
rateLimitNote: 'Limit: 5 boards/hour per IP',
rbacNote: 'Secure URL Fragment Key Auth',
// Saved Boards
savedBoardsTitle: 'Saved Boards',
savedBoardsSub: 'History stored securely in your browser',
noSavedBoards: 'No saved boards found.',
removeSaved: 'Remove from history',
// Board View Header
onlineUsers: 'Online Teammates',
shareInviteBtn: 'Share & Invite',
rotateKeyBtn: 'Rotate Member Key',
rotateKeyTooltip: 'Regenerate member key and revoke all previous member links',
deleteBoardTooltip: 'Permanently delete this board',
boardSettings: 'Board Settings',
autoMoveStaleSetting: 'Automatically move stale cards (> threshold hours) back to Backlog',
backToHome: 'Back to Home',
connectingToRealm: 'Connecting to board realm...',
accessForbidden: 'Access Denied',
accessForbiddenDesc: 'The authorization key in this URL is invalid or has expired.',
reEnterKey: 'Re-Enter Key',
boardLogsBtn: 'Activity Logs',
activityLogTitle: 'Movement & Activity History',
noActivityLogs: 'No card movements logged yet.',
// Columns & WIP
addColumn: 'Add Column',
columnTitlePlaceholder: 'Column name (e.g., QA Testing)...',
addNewTask: 'Add Task',
taskTitlePlaceholder: 'Write task title...',
enterToAdd: 'Press Enter ↵ to save',
cancel: 'Cancel',
add: 'Add',
save: 'Save',
noTasksInCol: 'No tasks in this column yet',
dragColTooltip: 'Drag to reorder column',
deleteColConfirm: 'Delete this column and all of its tasks?',
editColSettings: 'Column Settings & WIP Limit',
wipLimitLabel: 'WIP Limit (Max Cards):',
wipUnlimited: '0 = Unlimited',
autoStaleHoursLabel: 'Stale Inactivity Limit (Hours):',
wipLimitReached: 'WIP Limit Reached',
wipWarningModal: 'This column has reached its maximum Work-In-Progress (WIP) limit. Please finish active tasks first!',
// Ticket Modal / Editor
taskTitle: 'Task Title',
taskDesc: 'Task Description / Specs',
taskDescPlaceholder: 'Write specifications, acceptance criteria, or key implementation notes...',
priorityLabel: 'Priority / Urgency',
tagsLabel: 'Tags (Press Enter)',
tagsPlaceholder: 'e.g. FRONTEND, BUG, BACKEND...',
assigneeLabel: 'Assignee',
assigneePlaceholder: 'e.g. Alex Morgan',
dueDateLabel: 'Due Date',
subtasksLabel: 'Subtask Checklist',
addSubtaskPlaceholder: 'Add a new subtask... (Enter)',
blockedLabel: 'Blocker Status (Blocked)',
markBlocked: 'Mark as Blocked',
blockedReasonLabel: 'Blocker Explanation:',
blockedReasonPlaceholder: 'e.g. Waiting for 3rd-party API approval...',
staleIndicator: 'Stale Card',
staleSince: 'No movement for',
overdue: 'Overdue',
dueToday: 'Due Today',
autoSaving: 'Auto-saving...',
synced: 'Synced',
isTypingTitle: 'is typing title...',
isTypingDesc: 'is typing description...',
isEditingGeneral: 'is editing this task...',
deleteTask: 'Delete Task',
close: 'Close',
deleteTaskConfirm: 'Are you sure you want to delete this task?',
cardMovementHistory: 'Card Movement History (Database Log)',
// Priorities
prioLow: 'Low',
prioMedium: 'Medium',
prioHigh: 'High',
prioUrgent: 'Urgent',
// Success Share Modal
boardCreatedTitle: 'TautKan Board Created!',
boardName: 'Board Name',
adminLinkTitle: 'Admin Key Link (Superadmin)',
adminLinkWarning: 'DO NOT SHARE! ADMIN ACCESS ONLY',
adminLinkSavedNote: 'This link is saved in your browser. Bookmark it to maintain management permissions.',
memberLinkTitle: 'Team Member Invite Link',
memberLinkBadge: 'FOR TEAMMATES',
copied: 'Copied!',
copy: 'Copy',
shareWhatsApp: 'Share via WhatsApp / Apps',
enterBoardBtn: 'Open Kanban Board',
// Key Rotation Modal
keyRotationTitle: 'Member Key Rotation',
keyRotationSubtitle: 'Regenerate Team Member Invite Link',
keyRotationWarningTitle: 'Warning: Invalidates Old Links!',
keyRotationWarningDesc: 'Generating a new member key will immediately revoke access for all members using old links and disconnect active sessions in real time.',
regenerateBtn: 'Regenerate Link',
rotatingBtn: 'Rotating...',
newLinkActiveTitle: 'New Member Link Active!',
newLinkActiveDesc: 'Old member links have been successfully invalidated. Share this new link with your team:',
done: 'Done',
// Key Prompt Modal
keyPromptTitle: 'Secret Key Required',
keyPromptDesc: 'This board is protected by a URL Fragment Key. Please paste your link (#admin=... or #member=...) or secret key below.',
keyPromptPlaceholder: 'Paste #admin=... or #member=... link or key',
unlockBoardBtn: 'Unlock Board',
keyPromptError: 'Please enter your key or invite link',
// Revoked Alert
sessionRevokedMsg: 'The superadmin regenerated the member invite link. Your session has been revoked.',
movementFailed: 'Task movement failed:',
footerText: 'TautKan • One link to manage it all • Dual-Code URL Fragment Real-Time Agile',
},
};
export const retroTranslations = {
id: {
...modernTranslations.id,
appBadge: '🕹️ 8-BIT RPG',
superadmin: 'OVERLORD / GUILD MASTER',
member: 'PARTY MEMBER',
heroTitle: 'QUEST REALM 8-BIT',
heroSubtitle: 'Bangun petualangan manajemen quest tim secara instan! Otorisasi URL Fragment Hash tanpa registrasi, chip-audio 8-bit, dan kolaborasi waktu nyata.',
createBoardHeader: 'FORGE QUEST REALM BARU',
boardTitleLabel: 'NAMA PETUALANGAN / REALM',
boardTitlePlaceholder: 'Contoh: SPRINT NETHERS / BOSS RAID...',
createBoardBtn: '⚔️ FORGE REALM BARU',
creatingBoardBtn: 'FORGING REALM...',
savedBoardsTitle: 'REALM TERSIMPAN SAYA',
onlineUsers: 'PARTY ONLINE',
shareInviteBtn: '📜 UNDANG GUILD',
rotateKeyBtn: '🗝️ ROTASI KUNCI',
boardLogsBtn: '📜 LOG QUEST',
activityLogTitle: 'GULUNGAN LOG RIWAYAT & PERGERAKAN QUEST',
addColumn: 'TAMBAH TAHAPAN QUEST',
addNewTask: 'TAMBAH QUEST',
taskTitlePlaceholder: 'Nama misi / quest...',
taskTitle: 'NAMA MISI / QUEST',
taskDesc: 'GULUNGAN DESKRIPSI & SPESIFIKASI',
taskDescPlaceholder: 'Tuliskan detail misi, item reward, atau catatan petualangan...',
priorityLabel: 'TINGKAT BAHAYA / URGENSI',
prioLow: 'WOOD (RENDAH)',
prioMedium: 'IRON (SEDANG)',
prioHigh: 'GOLD (TINGGI)',
prioUrgent: 'NETHERITE (MENDESAK)',
subtasksLabel: 'CHECKLIST SUB-QUEST',
addSubtaskPlaceholder: 'Tambah sub-quest baru... (Enter)',
dueDateLabel: 'TENGGAT WAKTU BOSS RAID',
blockedLabel: 'JEBAKAN / STATUS TERTAHAN (TRAPPED)',
markBlocked: '🚩 TERKENA JEBAKAN (BLOCKED)',
staleIndicator: 'QUEST TERTIDUR (STALE)',
wipLimitReached: 'MANA WIP LIMIT PENUH',
wipWarningModal: 'Tahapan ini kehabisan Mana (WIP Limit Penuh)! Selesaikan quest yang aktif sebelum memulai quest baru.',
cardMovementHistory: 'GULUNGAN PERJALANAN QUEST (LOG DB)',
footerText: 'TautKan • 8-Bit Retro Mode • Satu tautan untuk semua urusan • Real-Time RPG Quest Board',
},
en: {
...modernTranslations.en,
appBadge: '🕹️ 8-BIT RPG',
superadmin: 'OVERLORD / GUILD MASTER',
member: 'PARTY MEMBER',
heroTitle: '8-BIT QUEST REALM',
heroSubtitle: 'Forge your team quest adventure instantly! URL Fragment Hash authorization, 8-bit chip audio, and real-time multiplayer board synchronization.',
createBoardHeader: 'FORGE NEW QUEST REALM',
boardTitleLabel: 'REALM / ADVENTURE NAME',
boardTitlePlaceholder: 'e.g. SPRINT NETHER / BOSS RAID...',
createBoardBtn: '⚔️ FORGE REALM',
creatingBoardBtn: 'FORGING REALM...',
savedBoardsTitle: 'SAVED QUEST REALMS',
onlineUsers: 'PARTY ONLINE',
shareInviteBtn: '📜 SUMMON PARTY',
rotateKeyBtn: '🗝️ ROTATE KEY',
boardLogsBtn: '📜 QUEST LOGS',
activityLogTitle: 'QUEST JOURNEY & ACTIVITY SCROLL',
addColumn: 'ADD QUEST STAGE',
addNewTask: 'ADD QUEST',
taskTitlePlaceholder: 'Quest / Mission name...',
taskTitle: 'QUEST / MISSION TITLE',
taskDesc: 'QUEST SCROLL & SPECIFICATIONS',
taskDescPlaceholder: 'Describe quest requirements, loot rewards, or tactical notes...',
priorityLabel: 'DANGER / URGENCY TIER',
prioLow: 'WOOD (LOW)',
prioMedium: 'IRON (MEDIUM)',
prioHigh: 'GOLD (HIGH)',
prioUrgent: 'NETHERITE (URGENT)',
subtasksLabel: 'SUB-QUEST CHECKLIST',
addSubtaskPlaceholder: 'Add new sub-quest... (Enter)',
dueDateLabel: 'BOSS RAID DUE DATE',
blockedLabel: 'TRAP / BLOCKED STATUS',
markBlocked: '🚩 TRAPPED IN BLOCKER',
staleIndicator: 'DORMANT QUEST (STALE)',
wipLimitReached: 'MANA WIP LIMIT FULL',
wipWarningModal: 'This stage has reached its maximum Mana capacity (WIP Limit)! Finish ongoing quests first.',
cardMovementHistory: 'QUEST MOVEMENT SCROLL (DB LOG)',
footerText: 'TautKan • 8-Bit Retro Mode • One link to manage it all • Real-Time RPG Quest Board',
},
};
const LANG_KEY = 'tautkan_lang';
export function getLanguage(): Language {
try {
const saved = localStorage.getItem(LANG_KEY) as Language;
if (saved === 'id' || saved === 'en') return saved;
} catch {}
return 'id'; // Default to Bahasa Indonesia
}
export function setLanguage(lang: Language): void {
try {
localStorage.setItem(LANG_KEY, lang);
} catch {}
}
export function useTranslation() {
const currentLang = getLanguage();
const isRetro = getRetroMode();
const activeDict = isRetro ? retroTranslations[currentLang] : modernTranslations[currentLang];
const fallbackDict = modernTranslations['id'];
const t = (key: keyof typeof modernTranslations['id']): string => {
return (activeDict as any)?.[key] || (fallbackDict as any)?.[key] || (key as string);
};
return { t, currentLang, isRetro };
}
+37
View File
@@ -0,0 +1,37 @@
export type ViewMode = 'modern' | 'retro';
const MODE_KEY = 'tautkan_view_mode';
export function getRetroMode(): boolean {
try {
const saved = localStorage.getItem(MODE_KEY);
return saved === 'retro';
} catch {
return false;
}
}
export function setRetroMode(isRetro: boolean): void {
try {
localStorage.setItem(MODE_KEY, isRetro ? 'retro' : 'modern');
applyRetroMode(isRetro);
} catch {}
}
export function toggleRetroMode(): boolean {
const current = getRetroMode();
const next = !current;
setRetroMode(next);
return next;
}
export function applyRetroMode(isRetro: boolean): void {
const root = document.documentElement;
if (isRetro) {
root.classList.add('retro-mode');
root.classList.remove('modern-mode');
} else {
root.classList.remove('retro-mode');
root.classList.add('modern-mode');
}
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Retro 8-bit sound synthesizer using Web Audio API.
* No external MP3/WAV files required!
*/
class RetroSoundEffects {
private ctx: AudioContext | null = null;
private enabled: boolean = true;
constructor() {
const saved = localStorage.getItem('retro_sound_enabled');
this.enabled = saved !== null ? saved === 'true' : true;
}
private getContext(): AudioContext | null {
if (!this.enabled) return null;
if (!this.ctx) {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
if (AudioCtx) {
this.ctx = new AudioCtx();
}
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume().catch(() => {});
}
return this.ctx;
}
public isEnabled(): boolean {
return this.enabled;
}
public toggle(): boolean {
this.enabled = !this.enabled;
localStorage.setItem('retro_sound_enabled', String(this.enabled));
if (this.enabled) {
this.playClick();
}
return this.enabled;
}
/**
* UI Click / Blip
*/
public playClick() {
const ctx = this.getContext();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(440, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.1, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.08);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.08);
} catch {
// Audio context might be restricted before interaction
}
}
/**
* Card Drop / Stone placement
*/
public playDrop() {
const ctx = this.getContext();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(220, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.12);
gain.gain.setValueAtTime(0.2, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.12);
} catch {}
}
/**
* Quest Completed / Victory Fanfare
*/
public playVictory() {
const ctx = this.getContext();
if (!ctx) return;
try {
const notes = [392.0, 523.25, 659.25, 783.99]; // G4, C5, E5, G5
notes.forEach((freq, idx) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, ctx.currentTime + idx * 0.09);
gain.gain.setValueAtTime(0.12, ctx.currentTime + idx * 0.09);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + idx * 0.09 + 0.15);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(ctx.currentTime + idx * 0.09);
osc.stop(ctx.currentTime + idx * 0.09 + 0.16);
});
} catch {}
}
/**
* Field Lock / Warning beep
*/
public playLock() {
const ctx = this.getContext();
if (!ctx) return;
try {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(320, ctx.currentTime);
osc.frequency.setValueAtTime(260, ctx.currentTime + 0.06);
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.14);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.14);
} catch {}
}
}
export const soundFx = new RetroSoundEffects();
+144
View File
@@ -0,0 +1,144 @@
import { SavedBoard, Role } from '../types/index.js';
const STORAGE_KEY = 'tautkan_saved_boards';
const OLD_STORAGE_KEY = 'retro_kanban_saved_boards';
const USER_KEY = 'tautkan_user_info';
const OLD_USER_KEY = 'retro_kanban_user_info';
const COOKIE_NAME = 'tautkan_user_profile';
// Cookie Helpers
function getCookie(name: string): string | null {
try {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'));
if (match) return decodeURIComponent(match[2]);
} catch {}
return null;
}
function setCookie(name: string, value: string, days = 365): void {
try {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`;
} catch {}
}
export function getSavedBoards(): (SavedBoard & { key?: string })[] {
try {
let raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
raw = localStorage.getItem(OLD_STORAGE_KEY);
}
if (!raw) return [];
return JSON.parse(raw);
} catch {
return [];
}
}
export function saveBoardToStorage(board: { id: string; title: string; role: Role; key: string }): void {
try {
const list = getSavedBoards();
const hash = board.role === 'admin' ? `#admin=${board.key}` : `#member=${board.key}`;
const url = `/b/${board.id}${hash}`;
const filtered = list.filter((b) => b.id !== board.id);
filtered.unshift({
id: board.id,
title: board.title || 'Kanban Board',
role: board.role,
key: board.key,
url,
lastVisited: Date.now(),
});
// Keep max 20 boards
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered.slice(0, 20)));
} catch (err) {
console.error('Failed to save board to localStorage:', err);
}
}
export function getSavedKeyForBoard(boardId: string): { key: string; role: Role } | null {
try {
const list = getSavedBoards();
const found = list.find((b) => b.id === boardId);
if (found) {
if (found.key) {
return { key: found.key, role: found.role };
}
if (found.url && found.url.includes('#admin=')) {
return { key: found.url.split('#admin=')[1].split('&')[0], role: 'admin' };
}
if (found.url && found.url.includes('#member=')) {
return { key: found.url.split('#member=')[1].split('&')[0], role: 'member' };
}
}
} catch {}
return null;
}
export function removeSavedBoard(id: string): void {
try {
const list = getSavedBoards().filter((b) => b.id !== id);
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
} catch {}
}
export function getUserProfile(): { userId: string; userName: string } {
// 1. Try LocalStorage
try {
let raw = localStorage.getItem(USER_KEY) || localStorage.getItem(OLD_USER_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.userName && parsed.userId) {
// Sync to cookie
setCookie(COOKIE_NAME, JSON.stringify(parsed));
return parsed;
}
}
} catch {}
// 2. Try Cookie fallback
try {
const cookieRaw = getCookie(COOKIE_NAME);
if (cookieRaw) {
const parsed = JSON.parse(cookieRaw);
if (parsed.userName && parsed.userId) {
// Sync to localStorage
localStorage.setItem(USER_KEY, JSON.stringify(parsed));
return parsed;
}
}
} catch {}
// 3. Generate new profile once
const adjectives = ['Pixel', 'Swift', 'Nova', 'Cyber', 'Zenith', 'Vortex', 'Echo', 'Nexus', 'Solar', 'Pulse'];
const nouns = ['Pilot', 'Maker', 'Dev', 'Voyager', 'Lead', 'Master', 'Engineer', 'Architect', 'Scout', 'Agent'];
const randAdj = adjectives[Math.floor(Math.random() * adjectives.length)];
const randNoun = nouns[Math.floor(Math.random() * nouns.length)];
const randNum = Math.floor(10 + Math.random() * 90);
const profile = {
userId: 'usr_' + Math.random().toString(36).substring(2, 11),
userName: `${randAdj}${randNoun}_${randNum}`,
};
try {
localStorage.setItem(USER_KEY, JSON.stringify(profile));
setCookie(COOKIE_NAME, JSON.stringify(profile));
} catch {}
return profile;
}
export function setUserName(userName: string): { userId: string; userName: string } {
const profile = getUserProfile();
profile.userName = userName.trim() || profile.userName;
try {
localStorage.setItem(USER_KEY, JSON.stringify(profile));
setCookie(COOKIE_NAME, JSON.stringify(profile));
// Dispatch global event for other components & open tabs
window.dispatchEvent(new CustomEvent('user_profile_updated', { detail: profile }));
} catch {}
return profile;
}
+33
View File
@@ -0,0 +1,33 @@
export type Theme = 'dark' | 'light';
const THEME_KEY = 'tautkan_theme';
const OLD_THEME_KEY = 'retro_kanban_theme';
export function getTheme(): Theme {
try {
const saved = (localStorage.getItem(THEME_KEY) || localStorage.getItem(OLD_THEME_KEY)) as Theme;
if (saved === 'dark' || saved === 'light') return saved;
} catch {}
return 'light'; // Default to modern Light mode
}
export function applyTheme(theme: Theme): void {
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
root.classList.remove('light');
} else {
root.classList.remove('dark');
root.classList.add('light');
}
try {
localStorage.setItem(THEME_KEY, theme);
} catch {}
}
export function toggleTheme(): Theme {
const current = getTheme();
const next = current === 'dark' ? 'light' : 'dark';
applyTheme(next);
return next;
}