Files
Umbrella/frontend/src/routes/project/Kanban.svelte
2025-11-26 08:47:23 +01:00

267 lines
9.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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);
let stateList = {};
$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(window.history.state, '', url);
}
function byName(a,b) {
return a.name.localeCompare(b.name);
}
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';
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();
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){
const json = await resp.json();
project = json;
// current user first
users.push(user);
if (!tasks[user.id]) tasks[user.id] = tasks[user.id] = {};
for (var member of Object.values(json.members).map(a => a.user).sort(byName)){
if (member.id != user.id) users.push(member);
if (!tasks[member.id]) tasks[member.id] = {};
for (var state of Object.keys(json.allowed_states)) tasks[member.id][state] = {};
}
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 is_custom(state){
return [10,20,40,60,100].includes(state);
}
function openTask(task_id){
window.open(`/task/${task_id}/view`, '_blank').focus();
}
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);
}
}
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 users as u}
<div class="user">{u.name}</div>
{#each Object.entries(project.allowed_states) as [state,name]}
<div class={['state_'+state, is_custom(state) ? '':'state_custom' ,highlight.user == u.id && highlight.state == state ? 'highlight':'']} ondragover={ev => hover(ev,u.id,state)} ondragleave={e => delete highlight.user} ondrop={ev => drop(u.id,state)} >
{#each Object.values(tasks[u.id][state]).sort(byName) 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}
<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}