first init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user