|
|
<script> |
|
|
import { onMount } from 'svelte'; |
|
|
import { useTinyRouter } from 'svelte-tiny-router'; |
|
|
|
|
|
import { api, target } from '../../urls.svelte.js'; |
|
|
import { error, yikes } from '../../warn.svelte'; |
|
|
import { t } from '../../translations.svelte.js'; |
|
|
import { user } from '../../user.svelte.js'; |
|
|
|
|
|
import Card from './KanbanCard.svelte'; |
|
|
import LineEditor from '../../Components/LineEditor.svelte'; |
|
|
import MarkdownEditor from '../../Components/MarkdownEditor.svelte'; |
|
|
|
|
|
let { id } = $props(); |
|
|
let descr = $state(false); |
|
|
let filter_input = $state(''); |
|
|
let router = useTinyRouter(); |
|
|
if (router.hasQueryParam('filter')) filter_input = router.getQueryParam('filter'); |
|
|
let dragged = null; |
|
|
let highlight = $state({}); |
|
|
let filter = $derived(filter_input.toLowerCase()); |
|
|
let project = $state(null); |
|
|
let tasks = $state({}); |
|
|
let users = {}; |
|
|
let columns = $derived(project.allowed_states?Object.keys(project.allowed_states).length+1:1); |
|
|
const controller = new AbortController(); |
|
|
|
|
|
$effect(() => updateUrl(filter_input)); |
|
|
|
|
|
async function do_archive(ex){ |
|
|
ex.preventDefault(); |
|
|
var task = dragged; |
|
|
const url = api(`task/${task.id}`); |
|
|
const resp = await fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'PATCH', |
|
|
body : JSON.stringify({no_index:true}) |
|
|
}); |
|
|
delete highlight.archive; |
|
|
if (resp.ok){ |
|
|
yikes(); |
|
|
delete tasks[task.assignee][task.status][task.id] |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateUrl(){ |
|
|
let url = window.location.origin + window.location.pathname; |
|
|
if (filter_input) url += '?filter=' + encodeURI(filter_input); |
|
|
window.history.replaceState(history.state, '', url); |
|
|
} |
|
|
|
|
|
async function create(name,user_id,state){ |
|
|
var url = api('task/add'); |
|
|
let task = { |
|
|
description : '', |
|
|
members : {}, |
|
|
name : name, |
|
|
project_id : +id, |
|
|
status : { code : +state} |
|
|
} |
|
|
task.members[user_id] = { permission: { name : 'ASSIGNEE' }}; |
|
|
task.members[user.id] = { permission: { name : 'OWNER' }}; |
|
|
const resp = await fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'POST', |
|
|
body : JSON.stringify(task) |
|
|
}); |
|
|
if (resp.ok) { |
|
|
task = await resp.json(); |
|
|
task.assignee = user_id; |
|
|
if (!tasks[user_id]) tasks[user_id] = {}; |
|
|
if (!tasks[user_id][state]) tasks[user_id][state] = {}; |
|
|
tasks[user_id][state][task.id] = task; |
|
|
yikes(); |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
async function drop(user_id,state){ |
|
|
let task = dragged; |
|
|
dragged = null; |
|
|
highlight = {}; |
|
|
|
|
|
if (task.assignee == user_id && task.status == state) return; // no change |
|
|
let patch = {members:{},status:+state} |
|
|
patch.members[user_id] = 'ASSIGNEE'; |
|
|
//console.log('sending patch:',patch) |
|
|
const url = api(`task/${task.id}`); |
|
|
const resp = await fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'PATCH', |
|
|
body : JSON.stringify(patch) |
|
|
}); |
|
|
if (resp.ok){ |
|
|
delete tasks[task.assignee][task.status][task.id] |
|
|
if (!tasks[user_id]) tasks[user_id] = {} |
|
|
if (!tasks[user_id][state]) tasks[user_id][state] = {} |
|
|
tasks[user_id][state][task.id] = task; |
|
|
task.assignee = user_id; |
|
|
task.status = state; |
|
|
yikes(); |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
async function load(){ |
|
|
try { |
|
|
await loadProject(); |
|
|
await loadTasks({project_id:+id,parent_task_id:0}); |
|
|
} catch (ignored) {} |
|
|
} |
|
|
|
|
|
async function loadProject(){ |
|
|
const url = api(`project/${id}`); |
|
|
const resp = await fetch(url,{credentials:'include'}); |
|
|
if (resp.ok){ |
|
|
project = await resp.json(); |
|
|
for (var uid of Object.keys(project.members)){ |
|
|
let member = project.members[uid]; |
|
|
users[uid] = member.user.name; |
|
|
if (!tasks[uid]) tasks[uid] = {}; |
|
|
} |
|
|
yikes(); |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadTasks(selector){ |
|
|
const url = api('task/list'); |
|
|
selector.show_closed = true; |
|
|
selector.no_index = true; |
|
|
var resp = await fetch(url,{ |
|
|
credentials :'include', |
|
|
method : 'POST', |
|
|
body : JSON.stringify(selector) |
|
|
}); |
|
|
if (resp.ok){ |
|
|
var json = await resp.json(); |
|
|
for (var task_id of Object.keys(json)) { |
|
|
let task = json[task_id]; |
|
|
if (task.no_index) continue; |
|
|
let state = task.status; |
|
|
let owner = null; |
|
|
let assignee = null; |
|
|
for (var user_id of Object.keys(task.members)){ |
|
|
var member = task.members[user_id]; |
|
|
if (member.permission.name == 'OWNER') owner = user_id; |
|
|
if (member.permission.name == 'ASSIGNEE') assignee = user_id; |
|
|
|
|
|
} |
|
|
if (!assignee) assignee = owner; |
|
|
task.assignee = assignee; |
|
|
if (!tasks[assignee]) tasks[assignee] = {}; |
|
|
if (!tasks[assignee][state]) tasks[assignee][state] = {}; |
|
|
tasks[assignee][state][task_id] = task; |
|
|
} |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
} |
|
|
|
|
|
function hover(ev,user_id,state){ |
|
|
ev.preventDefault(); |
|
|
highlight = {user:user_id,state:state}; |
|
|
} |
|
|
|
|
|
function hover_archive(ev){ |
|
|
ev.preventDefault(); |
|
|
highlight.archive = true; |
|
|
} |
|
|
|
|
|
function openTask(task_id){ |
|
|
controller.abort(); |
|
|
router.navigate(`/task/${task_id}/view`) |
|
|
} |
|
|
|
|
|
async function save_bookmark(){ |
|
|
const user_ids = Object.values(project.members).map(member => member.user.id); |
|
|
const data = { |
|
|
url: location.href, |
|
|
tags: ['Kanban', project.name, filter_input], |
|
|
comment: `${project.name}: ${filter_input}`, |
|
|
share: user_ids |
|
|
} |
|
|
const url = api('bookmark'); |
|
|
const resp = await fetch(url,{ |
|
|
credentials : 'include', |
|
|
method : 'POST', |
|
|
body : JSON.stringify(data) |
|
|
}); |
|
|
if (resp.ok) { |
|
|
yikes(); |
|
|
router.navigate('/bookmark'); |
|
|
} else { |
|
|
error(resp); |
|
|
} |
|
|
|
|
|
console.log(data); |
|
|
} |
|
|
|
|
|
onMount(load); |
|
|
</script> |
|
|
|
|
|
<svelte:head> |
|
|
<title>Umbrella – {project?.name}</title> |
|
|
</svelte:head> |
|
|
|
|
|
{#if project} |
|
|
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1> |
|
|
{/if} |
|
|
|
|
|
{#if project} |
|
|
<fieldset class="kanban description {descr?'active':''}" onclick={e => descr = !descr}> |
|
|
<legend>{t('description')} – {t('expand_on_click')}</legend> |
|
|
{@html target(project.description.rendered)} |
|
|
</fieldset> |
|
|
<div class="kanban" style="display: grid; grid-template-columns: {`repeat(${columns}, auto)`}"> |
|
|
<span class="filter"> |
|
|
<input type="text" bind:value={filter_input} autofocus /> |
|
|
{t('filter')} |
|
|
<button style="visibility:{filter_input ? 'visible' : 'hidden'}" onclick={save_bookmark}> |
|
|
<span class="symbol"></span> {t('save_object',{object:t('bookmark')})} |
|
|
</button> |
|
|
</span> |
|
|
<div class="head">{t('user')}</div> |
|
|
{#if project.allowed_states} |
|
|
{#each Object.entries(project.allowed_states) as [sid,state]} |
|
|
<div class="head">{sid%10?state:t('state_'+state.toLowerCase())}</div> |
|
|
{/each} |
|
|
{/if} |
|
|
{#each Object.entries(tasks) as [uid,stateList]} |
|
|
<div class="user">{users[uid]}</div> |
|
|
{#each Object.entries(project.allowed_states) as [state,name]} |
|
|
<div class={['state_'+state, highlight.user == uid && highlight.state == state ? 'highlight':'']} ondragover={ev => hover(ev,uid,state)} ondragleave={e => delete highlight.user} ondrop={ev => drop(uid,state)} > |
|
|
{#if stateList[state]} |
|
|
{#each Object.values(stateList[state]).sort((a,b) => a.name.localeCompare(b.name)) as task} |
|
|
{#if !filter || task.name.toLowerCase().includes(filter) || (task.tags && task.tags.filter(tag => tag.toLowerCase().includes(filter)).length)} |
|
|
<Card onclick={e => openTask(task.id)} ondragstart={ev => dragged=task} {task} tag_colors={project.tag_colors} /> |
|
|
{/if} |
|
|
{/each} |
|
|
{/if} |
|
|
<div class="add_task"> |
|
|
<LineEditor value={t('add_object',{object:t('task')})} editable={true} onSet={(name) => create(name,uid,state)}/> |
|
|
</div> |
|
|
</div> |
|
|
{/each} |
|
|
{/each} |
|
|
</div> |
|
|
<div class="archive {highlight.archive?'hover':''}" ondragover={hover_archive} ondragleave={e => delete highlight.archive} ondrop={do_archive} > |
|
|
{t('hide')} |
|
|
</div> |
|
|
{/if}
|
|
|
|