1
Fork 0
mirror of https://github.com/Steffo99/todocolors.git synced 2024-10-16 07:17:28 +00:00

Implement rudimentary task journal

This commit is contained in:
Steffo 2023-09-29 09:32:42 +02:00
parent 33cb947da3
commit 352c8378d0
123 changed files with 2122 additions and 799 deletions

7
.idea/discord.xml Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

View file

@ -1,9 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
<component name="ProjectRootManager">
<output url="file://$PROJECT_DIR$/out" />
</component>

View file

@ -8,9 +8,7 @@
<arguments value="--port=8081" />
<node-interpreter value="project" />
<envs>
<env name="NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL" value="http://localhost:8080" />
<env name="TODOBLUE_SITE_DESCRIPTION" value="Development version of Todoblue" />
<env name="TODOBLUE_SITE_NAME" value="Tododeve" />
<env name="NEXT_PUBLIC_TODOBLUE_OVERRIDE_BASE_URL" value="http://ethernet.nitro.home.steffo.eu:8080" />
</envs>
<method v="2" />
</configuration>

View file

@ -15,3 +15,17 @@ A self-hostable multiplayer todo app with Redis, Rust, WebSockets and Next.js.
## Screenshots
![Screenshot of the application, detailing a nonsensical "Plan for conquering the world"](media/screenshot.png 'Screenshot of the application, detailing a nonsensical "Plan for conquering the world')
## Installation
To deploy your own instance of Todocolors, use the (Docker) `compose.yml` file included in `todopod/`, tweaking the `network_mode` and `ports` of the `caddy` container as you see appropriate.
Data will be stored in the `data/redis/rdata/` directory.
### Further customization
For more customization, make changes and then build your own Docker images using the provided `Dockerfile` in `todored/` and `todoblue/`.
## Credits & acknowledgements
TODO

View file

@ -1,6 +1,6 @@
{
"name": "todoblue",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",

View file

@ -1,6 +1,7 @@
'use client';
import {useCallback, useEffect, useReducer, Reducer} from "react"
import {Reducer, useCallback, useEffect, useReducer} from "react"
export interface WebSocketHandlerParams<E extends Event> {
event: E,

View file

@ -7,26 +7,54 @@
"cycleColumningButtonTitle": "Change column arrangement",
"cycleGroupingButtonTitle": "Change column task grouping",
"cycleSortingButtonTitle": "Change column task sorting",
"lockButtonTitle": "Lock board, preventing editing",
"unlockButtonTitle": "Unlock board, allowing editing",
"privacyButtonTitle": "Connected users",
"boardPreparing": "Preparing...",
"boardConnecting": "Connecting...",
"boardEmpty": "This board is empty.",
"boardDisconnecting": "Disconnecting...",
"boardDisconnected": "Disconnected, reconnecting in {{retryingInSeconds}} s",
"columnHeaderTaskImportanceHighest": "Critical",
"columnHeaderTaskImportanceHigh": "Important",
"columnHeaderTaskImportanceNormal": "Normal",
"columnHeaderTaskImportanceLow": "Optional",
"columnHeaderTaskImportanceLowest": "Irrelevant",
"columnHeaderTaskPriorityHighest": "Immediate",
"columnHeaderTaskPriorityHigh": "Urgent",
"columnHeaderTaskPriorityNormal": "Normal",
"columnHeaderTaskPriorityLow": "Sometime",
"columnHeaderTaskPriorityLowest": "Anytime",
"columnHeaderTaskStatusUnfinished": "Unfinished",
"columnHeaderTaskStatusInProgress": "In progress",
"columnHeaderTaskStatusComplete": "Complete",
"taskImportanceHighest": "Critical",
"taskImportanceHigh": "Important",
"taskImportanceNormal": "Normal",
"taskImportanceLow": "Optional",
"taskImportanceLowest": "Irrelevant",
"taskPriorityHighest": "Immediate",
"taskPriorityHigh": "Urgent",
"taskPriorityNormal": "Normal",
"taskPriorityLow": "Sometime",
"taskPriorityLowest": "Anytime",
"taskStatusNonExistent": "Non-existent",
"taskStatusUnfinished": "Unfinished",
"taskStatusInProgress": "In progress",
"taskStatusComplete": "Complete",
"taskStatusJournaled": "Journaled",
"taskIconBookmark": "Bookmark",
"taskIconCircle": "Circle",
"taskIconSquare": "Square",
"taskIconHeart": "Heart",
"taskIconStar": "Star",
"taskIconSun": "Sun",
"taskIconMoon": "Moon",
"taskIconEye": "Eye",
"taskIconHand": "Hand",
"taskIconHandshake": "Handshake",
"taskIconFaceSmile": "Smile",
"taskIconUser": "User",
"taskIconComment": "Comment",
"taskIconEnvelope": "Envelope",
"taskIconFile": "File",
"taskIconPaperPlane": "Paper plane",
"taskIconBuilding": "Building",
"taskIconFlag": "Flag",
"taskIconBell": "Bell",
"taskIconClock": "Clock",
"taskIconImage": "Image",
"taskButtonJournal": "Move this task to the Journal",
"taskButtonUnjournal": "Remove this task from the Journal",
"taskButtonDelete": "Delete this task",
"taskButtonRecreate": "Delete and go back to editing this task",
"editorPlaceholder": "What do you want to do?",
"taskButtonRecreate": "Delete and return to editing this task",
"editorPlaceholder": "What to do...?",
"noTitlePlaceholder": "Untitled board"
}

View file

@ -7,26 +7,54 @@
"cycleColumningButtonTitle": "Cambia disposizione colonne",
"cycleGroupingButtonTitle": "Cambia raggruppamento attività in colonne",
"cycleSortingButtonTitle": "Cambia ordinamento attività nelle colonne",
"lockButtonTitle": "Blocca il board, impedendone la modifica",
"unlockButtonTitle": "Sblocca il board, permettendone la modifica",
"privacyButtonTitle": "Utenti connessi",
"boardPreparing": "Preparazione...",
"boardConnecting": "Connessione...",
"boardEmpty": "Il board è vuoto.",
"boardEmpty": "Questo board è vuoto.",
"boardDisconnecting": "Disconnessione...",
"boardDisconnected": "Disconnesso, riconnessione tra {{retryingInSeconds}} s",
"columnHeaderTaskImportanceHighest": "Critica",
"columnHeaderTaskImportanceHigh": "Importante",
"columnHeaderTaskImportanceNormal": "Normale",
"columnHeaderTaskImportanceLow": "Opzionale",
"columnHeaderTaskImportanceLowest": "Irrelevante",
"columnHeaderTaskPriorityHighest": "Immediata",
"columnHeaderTaskPriorityHigh": "Urgente",
"columnHeaderTaskPriorityNormal": "Normale",
"columnHeaderTaskPriorityLow": "Senza fretta",
"columnHeaderTaskPriorityLowest": "Una qualche volta",
"columnHeaderTaskStatusUnfinished": "Da fare",
"columnHeaderTaskStatusInProgress": "In corso",
"columnHeaderTaskStatusComplete": "Completata",
"taskImportanceHighest": "Critico",
"taskImportanceHigh": "Importante",
"taskImportanceNormal": "Normale",
"taskImportanceLow": "Opzionale",
"taskImportanceLowest": "Irrelevante",
"taskPriorityHighest": "Immediato",
"taskPriorityHigh": "Urgente",
"taskPriorityNormal": "Normale",
"taskPriorityLow": "Senza fretta",
"taskPriorityLowest": "Prima o poi",
"taskStatusNonExistent": "Inesistente",
"taskStatusUnfinished": "Da fare",
"taskStatusInProgress": "In corso",
"taskStatusComplete": "Completato",
"taskStatusJournaled": "Salvato nel Diario",
"taskIconBookmark": "Segnalibro",
"taskIconCircle": "Cerchio",
"taskIconSquare": "Quadrato",
"taskIconHeart": "Cuore",
"taskIconStar": "Stella",
"taskIconSun": "Sole",
"taskIconMoon": "Luna",
"taskIconEye": "Occhio",
"taskIconHand": "Mano",
"taskIconHandshake": "Stretta di mano",
"taskIconFaceSmile": "Sorriso",
"taskIconUser": "Utente",
"taskIconComment": "Commento",
"taskIconEnvelope": "Busta",
"taskIconFile": "File",
"taskIconPaperPlane": "Aeroplano di carta",
"taskIconBuilding": "Edificio",
"taskIconFlag": "Bandiera",
"taskIconBell": "Campana",
"taskIconClock": "Orologio",
"taskIconImage": "Immagine",
"taskButtonJournal": "Sposta questa attività nel Diario",
"taskButtonUnjournal": "Rimuovi questa attività dal Diario",
"taskButtonDelete": "Elimina questa attività",
"taskButtonRecreate": "Elimina questa attività e torna a modificarla",
"editorPlaceholder": "Cosa vuoi fare?",
"editorPlaceholder": "Cosa fare...?",
"noTitlePlaceholder": "Board senza titolo"
}

View file

@ -1,5 +1,5 @@
"use client";
import "client-only";
import "client-only"
import {createInstance, i18n} from "i18next"
import resourcesToBackend from "i18next-resources-to-backend"
import {useEffect, useState} from "react"

View file

@ -1,6 +1,6 @@
import "server-only";
import {createInstance, i18n} from "i18next"
import resourcesToBackend from "i18next-resources-to-backend"
import "server-only"
async function init(lang: string, ns: string): Promise<i18n> {

View file

@ -1,6 +1,6 @@
import {KEBABIFIER} from "@/app/(utils)/(kebab)/kebabifier"
import {ReturnTypeOfUseState} from "@/app/(utils)/ReturnTypeOfUseState"
import {Dispatch, useCallback, useState} from "react"
import {useCallback, useState} from "react"
/**

View file

@ -7,6 +7,7 @@ import classNames from "classnames"
import {useRouter} from "next/navigation"
import {default as React, SyntheticEvent, useCallback, useEffect, useState} from "react"
export function CreatePrivateBoardPanel({lang}: {lang: string}) {
const {t} = useClientTranslation(lang, "root")
const router = useRouter();

View file

@ -3,10 +3,10 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useLowerKebabifier} from "@/app/(utils)/(kebab)"
import {faGlobe} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {useRouter} from "next/navigation"
import {default as React, SyntheticEvent, useCallback, useState} from "react"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
export function CreatePublicBoardPanel({lang}: {lang: string}) {

View file

@ -3,10 +3,10 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useLowerKebabifier} from "@/app/(utils)/(kebab)"
import {faKey} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {useRouter} from "next/navigation"
import {default as React, SyntheticEvent, useCallback, useState} from "react"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
export function KnownBoardsPanel({lang}: {lang: string}) {

View file

@ -1,6 +1,6 @@
import {useServerTranslation} from "@/app/(i18n)/server"
import style from "./RootHeader.module.css"
import {default as React} from "react"
import style from "./RootHeader.module.css"
export async function RootHeader({lang}: {lang: string}) {

View file

@ -1,14 +1,14 @@
import {CreateBoardChapter} from "@/app/[lang]/(page)/CreateBoardChapter"
import {ExistingBoardChapter} from "@/app/[lang]/(page)/ExistingBoardChapter"
import style from "./RootMain.module.css"
import {default as React} from "react"
import style from "./RootMain.module.css"
export async function RootMain({lang}: {lang: string}) {
return (
<main className={style.rootMain}>
<CreateBoardChapter lang={lang}/>
<ExistingBoardChapter lang={lang}/>
<CreateBoardChapter lang={lang}/>
</main>
)
}

View file

@ -0,0 +1,9 @@
import {ConnectBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/ConnectBoardChange"
import {DisconnectBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/DisconnectBoardChange"
import {LockBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/LockBoardChange"
import {StateBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/StateBoardChange"
import {TaskBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/TaskBoardChange"
import {TitleBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/TitleBoardChange"
export type BoardChange = TaskBoardChange | TitleBoardChange | ConnectBoardChange | DisconnectBoardChange | LockBoardChange | StateBoardChange;

View file

@ -1,6 +1,6 @@
/**
* **Object** signaling the connection of a new client to the board.
*/
export type ConnectBoardSignal = {
export type ConnectBoardChange = {
"Connect": string,
}

View file

@ -4,7 +4,7 @@ import {TaskId} from "@/app/[lang]/board/[board]/(api)/(task)"
/**
* **Object** signaling the deletion of the {@link Task} with the given {@link TaskId} from the board.
*/
export type DeleteTaskBoardSignal = {
export type DeleteTaskBoardChange = {
"Task": [
TaskId,
null

View file

@ -1,6 +1,6 @@
/**
* **Object** signaling the disconnection of a client from the board.
*/
export type DisconnectBoardSignal = {
export type DisconnectBoardChange = {
"Disconnect": string,
}

View file

@ -0,0 +1,3 @@
export type LockBoardChange = {
"Lock": boolean,
}

View file

@ -0,0 +1,6 @@
import {BoardState} from "@/app/[lang]/board/[board]/(api)/(state)/BoardState"
export type StateBoardChange = {
"State": BoardState,
}

View file

@ -0,0 +1,8 @@
import {DeleteTaskBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/DeleteTaskBoardChange"
import {UpdateTaskBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/UpdateTaskBoardChange"
/**
* **Object** signaling an alteration to a {@link Task} related to the board.
*/
export type TaskBoardChange = UpdateTaskBoardChange | DeleteTaskBoardChange;

View file

@ -1,6 +1,6 @@
/**
* **Object** signaling a change to the title of the board.
*/
export type TitleBoardSignal = {
export type TitleBoardChange = {
"Title": string,
}

View file

@ -1,9 +1,10 @@
import {Task, TaskId} from "@/app/[lang]/board/[board]/(api)/(task)"
/**
* **Object** signaling the creation of a new {@link Task} with the given {@link TaskId} or a modification of the {@link Task} already on the board with the given {@link TaskId}.
*/
export type UpdateTaskBoardSignal = {
export type UpdateTaskBoardChange = {
"Task": [
TaskId,
Task,

View file

@ -0,0 +1,6 @@
export type {UpdateTaskBoardChange} from "./UpdateTaskBoardChange"
export type {DeleteTaskBoardChange} from "./DeleteTaskBoardChange"
export type {TaskBoardChange} from "./TaskBoardChange"
export type {TitleBoardChange} from "./TitleBoardChange"
export type {LockBoardChange} from "./LockBoardChange"
export type {BoardChange} from "./BoardChange"

View file

@ -1,8 +1,10 @@
import {LockBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/LockBoardRequest"
import {TaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/TaskBoardRequest"
import {TitleBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/TitleBoardRequest"
import {TrimBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/TrimBoardRequest"
/**
* **Object** requesting the server to perform a certain action on the board.
*/
export type BoardRequest = TitleBoardRequest | TaskBoardRequest;
export type BoardRequest = TitleBoardRequest | TaskBoardRequest | LockBoardRequest | TrimBoardRequest;

View file

@ -1,5 +1,6 @@
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
/**
* **Object** requesting the creation of a new {@link Task} on the board.
*/

View file

@ -0,0 +1,3 @@
export type LockBoardRequest = {
"Lock": boolean,
}

View file

@ -1,5 +1,6 @@
import {Task, TaskId} from "@/app/[lang]/board/[board]/(api)/(task)"
/**
* **Object** requesting the modification of the {@link Task} already on the board with the given {@link TaskId}.
*/

View file

@ -0,0 +1,3 @@
export type TrimBoardRequest = {
"Trim": null,
}

View file

@ -2,4 +2,6 @@ export type {ModifyTaskBoardRequest} from "./ModifyTaskBoardRequest"
export type {DeleteTaskBoardRequest} from "./DeleteTaskBoardRequest"
export type {TaskBoardRequest} from "./TaskBoardRequest"
export type {TitleBoardRequest} from "./TitleBoardRequest"
export type {LockBoardRequest} from "./LockBoardRequest"
export type {TrimBoardRequest} from "./TrimBoardRequest"
export type {BoardRequest} from "./BoardRequest"

View file

@ -1,7 +0,0 @@
import {ConnectBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/ConnectBoardSignal"
import {DisconnectBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/DisconnectBoardSignal"
import {TaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/TaskBoardSignal"
import {TitleBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/TitleBoardSignal"
export type BoardSignal = TaskBoardSignal | TitleBoardSignal | ConnectBoardSignal | DisconnectBoardSignal;

View file

@ -1,8 +0,0 @@
import {DeleteTaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/DeleteTaskBoardSignal"
import {UpdateTaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/UpdateTaskBoardSignal"
/**
* **Object** signaling an alteration to a {@link Task} related to the board.
*/
export type TaskBoardSignal = UpdateTaskBoardSignal | DeleteTaskBoardSignal;

View file

@ -1,5 +0,0 @@
export type {UpdateTaskBoardSignal} from "./UpdateTaskBoardSignal"
export type {DeleteTaskBoardSignal} from "./DeleteTaskBoardSignal"
export type {TaskBoardSignal} from "./TaskBoardSignal"
export type {TitleBoardSignal} from "./TitleBoardSignal"
export type {BoardSignal} from "./BoardSignal"

View file

@ -6,6 +6,7 @@ import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
*/
export type BoardState = {
title: string,
tasksById: {[key: string]: Task},
connectedClients: string[],
tasks: {[key: string]: Task},
clients: string[],
locked: boolean,
}

View file

@ -1,59 +1,61 @@
import {BoardSignal, DeleteTaskBoardSignal, TaskBoardSignal, TitleBoardSignal, UpdateTaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)"
import {ConnectBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/ConnectBoardSignal"
import {DisconnectBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/DisconnectBoardSignal"
import {ConnectBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/ConnectBoardChange"
import {DisconnectBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/DisconnectBoardChange"
import {StateBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)/StateBoardChange"
import {BoardState} from "@/app/[lang]/board/[board]/(api)/(state)/BoardState"
import {DEFAULT_BOARD_STATE} from "@/app/[lang]/board/[board]/(api)/(state)/defaultBoardState"
import {BoardChange, DeleteTaskBoardChange, LockBoardChange, TaskBoardChange, TitleBoardChange, UpdateTaskBoardChange} from "../(change)"
/**
* **Reducer** updating a {@link BoardState} with a single new {@link BoardSignal}.
* **Reducer** updating a {@link BoardState} with a single new {@link BoardChange}.
*
* If `null` is passed as signal, the state is reset to the default.
* If `null` is passed as change, the state is reset to the default.
*
* If an unknown signal is passed, the state is not altered.
*
* @param state The state to update.
* @param action The action to perform on the state.
* @param change The action to perform on the state.
*/
export function boardReducer(state: BoardState, action: BoardSignal | null) {
if(action === null) {
export function boardReducer(state: BoardState, change: BoardChange | null) {
if(change === null) {
console.debug("[boardReducer] Initializing state...");
return DEFAULT_BOARD_STATE
}
else if("Title" in action) {
const titleAction = action as TitleBoardSignal;
const title = titleAction["Title"]
else if("Title" in change) {
const titleChange = change as TitleBoardChange;
const title = titleChange["Title"]
console.debug("[boardReducer] Setting board title to:", title)
return {...state, title}
}
else if("Task" in action) {
const taskAction = action as TaskBoardSignal;
const task = taskAction["Task"][1]
const tasksById = {...state.tasksById}
else if("Task" in change) {
const taskChange = change as TaskBoardChange;
const task = taskChange["Task"][1]
const tasks = {...state.tasks}
if(task === null) {
const deleteAction = taskAction as DeleteTaskBoardSignal;
const id = deleteAction["Task"][0]
const deleteChange = taskChange as DeleteTaskBoardChange;
const id = deleteChange["Task"][0]
console.debug("[boardReducer] Deleting task:", id)
delete tasksById[id]
delete tasks[id]
}
else {
const updateAction = taskAction as UpdateTaskBoardSignal;
const id = updateAction["Task"][0] as string
const updateChange = taskChange as UpdateTaskBoardChange;
const id = updateChange["Task"][0] as string
console.debug("[boardReducer] Putting task:", id)
tasksById[id] = task
tasks[id] = task
}
return {...state, tasksById}
return {...state, tasks}
}
else if("Connect" in action) {
const connectAction = action as ConnectBoardSignal;
const id = connectAction["Connect"];
const connectedClients = [...state.connectedClients, id]
else if("Connect" in change) {
const connectChange = change as ConnectBoardChange;
const id = connectChange["Connect"];
const connectedClients = [...state.clients, id]
console.debug("[boardReducer] Adding new client:", id)
return {...state, connectedClients}
}
else if("Disconnect" in action) {
const disconnectAction = action as DisconnectBoardSignal;
const id = disconnectAction["Disconnect"];
const connectedClients = [...state.connectedClients]
else if("Disconnect" in change) {
const disconnectChange = change as DisconnectBoardChange;
const id = disconnectChange["Disconnect"];
const connectedClients = [...state.clients]
const clientIndex = connectedClients.indexOf(id)
if(clientIndex !== -1) {
connectedClients.splice(clientIndex, 1);
@ -61,12 +63,24 @@ export function boardReducer(state: BoardState, action: BoardSignal | null) {
return {...state, connectedClients}
}
else {
console.warn("[boardReducer] Received DisconnectBoardSignal without the client being connected in first place.")
console.warn("[boardReducer] Received DisconnectBoardChange without the client being connected in first place.")
return state
}
}
if("Lock" in change) {
const lockChange = change as LockBoardChange;
const locked = lockChange["Lock"]
console.debug("[boardReducer] Setting locked status to:", locked)
return {...state, locked}
}
if("State" in change) {
const stateChange = change as StateBoardChange;
// noinspection UnnecessaryLocalVariableJS
const state = stateChange["State"]
return {...state}
}
else {
console.warn("[boardReducer] Received unknown signal, ignoring:", action)
console.warn("[boardReducer] Received unknown signal, ignoring:", change)
return state
}
}

View file

@ -4,4 +4,4 @@ import {BoardState} from "@/app/[lang]/board/[board]/(api)/(state)/BoardState"
/**
* **Object** denoting the {@link BoardState} of a board where no {@link BoardAction}s have been performed.
*/
export const DEFAULT_BOARD_STATE: BoardState = {title: "", tasksById: {}, connectedClients: []}
export const DEFAULT_BOARD_STATE: BoardState = {title: "", tasks: {}, clients: [], locked: false}

View file

@ -1,13 +1,14 @@
"use client";
import {BoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)"
import {boardReducer} from "@/app/[lang]/board/[board]/(api)/(state)/boardReducer"
import {BoardState} from "@/app/[lang]/board/[board]/(api)/(state)/BoardState"
import {DEFAULT_BOARD_STATE} from "@/app/[lang]/board/[board]/(api)/(state)/defaultBoardState"
import {Reducer, useReducer} from "react"
import {BoardChange} from "../(change)"
export function useBoardState() {
const [boardState, processBoardSignal] = useReducer<Reducer<BoardState, BoardSignal | null>>(boardReducer, DEFAULT_BOARD_STATE)
const [boardState, processBoardChange] = useReducer<Reducer<BoardState, BoardChange | null>>(boardReducer, DEFAULT_BOARD_STATE)
return {boardState, processBoardSignal}
return {boardState, processBoardChange}
}

View file

@ -1,7 +1,6 @@
import {TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)/TaskIcon"
import {TaskImportance} from "@/app/[lang]/board/[board]/(api)/(task)/TaskImportance"
import {TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)/TaskPriority"
import {TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)/TaskStatus"
export type Task = {
@ -9,5 +8,8 @@ export type Task = {
icon: TaskIcon,
importance: TaskImportance,
priority: TaskPriority,
status: TaskStatus,
created_on: number | null,
started_on: number | null,
completed_on: number | null,
journaled_on: number | null,
}

View file

@ -1,8 +0,0 @@
/**
* **Enum** of possible stati a {@link Task} can be in.
*/
export enum TaskStatus {
Unfinished = "Unfinished",
InProgress = "InProgress",
Complete = "Complete",
}

View file

@ -1,7 +1,6 @@
export {TaskIcon} from "./TaskIcon"
export {TaskImportance} from "./TaskImportance"
export {TaskPriority} from "./TaskPriority"
export {TaskStatus} from "./TaskStatus"
export {TASK_ICON_TO_FONTAWESOME_SOLID, TASK_ICON_TO_FONTAWESOME_REGULAR} from "./taskIconMappings"
export type {Task} from "./Task"

View file

@ -1,9 +1,9 @@
import {TaskIcon} from "./TaskIcon"
import {faBell as faBellRegular, faBookmark as faBookmarkRegular, faBuilding as faBuildingRegular, faCircle as faCircleRegular, faClock as faClockRegular, faComment as faCommentRegular, faEnvelope as faEnvelopeRegular, faEye as faEyeRegular, faFaceSmile as faFaceSmileRegular, faFile as faFileRegular, faFlag as faFlagRegular, faHand as faHandRegular, faHandshake as faHandshakeRegular, faHeart as faHeartRegular, faImage as faImageRegular, faMoon as faMoonRegular, faPaperPlane as faPaperPlaneRegular, faSquare as faSquareRegular, faStar as faStarRegular, faSun as faSunRegular, faUser as faUserRegular} from "@fortawesome/free-regular-svg-icons"
import {faBell as faBellSolid, faBookmark as faBookmarkSolid, faBuilding as faBuildingSolid, faCircle as faCircleSolid, faClock as faClockSolid, faComment as faCommentSolid, faEnvelope as faEnvelopeSolid, faEye as faEyeSolid, faFaceSmile as faFaceSmileSolid, faFile as faFileSolid, faFlag as faFlagSolid, faHand as faHandSolid, faHandshake as faHandshakeSolid, faHeart as faHeartSolid, faImage as faImageSolid, faMoon as faMoonSolid, faPaperPlane as faPaperPlaneSolid, faSquare as faSquareSolid, faStar as faStarSolid, faSun as faSunSolid, faUser as faUserSolid} from "@fortawesome/free-solid-svg-icons"
import {faBell as faBellSolid, faBookmark as faBookmarkSolid, faBuilding as faBuildingSolid, faCircle as faCircleSolid, faClock as faClockSolid, faComment as faCommentSolid, faEnvelope as faEnvelopeSolid, faEye as faEyeSolid, faFaceSmile as faFaceSmileSolid, faFile as faFileSolid, faFlag as faFlagSolid, faHand as faHandSolid, faHandshake as faHandshakeSolid, faHeart as faHeartSolid, faImage as faImageSolid, faMoon as faMoonSolid, faPaperPlane as faPaperPlaneSolid, faSquare as faSquareSolid, faStar as faStarSolid, faSun as faSunSolid, faUser as faUserSolid, IconDefinition} from "@fortawesome/free-solid-svg-icons"
import {TaskIcon} from "./TaskIcon"
export const TASK_ICON_TO_FONTAWESOME_SOLID = {
export const TASK_ICON_TO_FONTAWESOME_SOLID: {[T in TaskIcon]: IconDefinition} = {
[TaskIcon.User]: faUserSolid,
[TaskIcon.Image]: faImageSolid,
[TaskIcon.Envelope]: faEnvelopeSolid,
@ -27,7 +27,7 @@ export const TASK_ICON_TO_FONTAWESOME_SOLID = {
[TaskIcon.Moon]: faMoonSolid,
}
export const TASK_ICON_TO_FONTAWESOME_REGULAR = {
export const TASK_ICON_TO_FONTAWESOME_REGULAR: {[T in TaskIcon]: IconDefinition} = {
[TaskIcon.User]: faUserRegular,
[TaskIcon.Image]: faImageRegular,
[TaskIcon.Envelope]: faEnvelopeRegular,

View file

@ -1,11 +1,11 @@
'use client';
import {useWs, WebSocketHandlerParams} from "@/app/(api)/useWs"
import {BoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {BoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)"
import {useBoardState} from "@/app/[lang]/board/[board]/(api)/(state)/useBoardState"
import {useBoardWsURL} from "@/app/[lang]/board/[board]/(api)/(ws)/useBoardWsURL"
import {useCallback, useMemo} from "react"
import {useWs, WebSocketHandlerParams} from "@/app/(api)/useWs"
import {BoardChange} from "../(change)"
/**
@ -16,20 +16,20 @@ import {useWs, WebSocketHandlerParams} from "@/app/(api)/useWs"
export function useBoardWs(boardName: string) {
const wsFullURL = useBoardWsURL(boardName)
const {boardState, processBoardSignal} = useBoardState();
const {boardState, processBoardChange} = useBoardState();
const {webSocket, webSocketState, webSocketBackoffMs} = useWs(wsFullURL, {
onopen: useCallback(({}) => {
console.debug("[useBoardWs] Opened connection, resetting BoardState to default:", boardName);
processBoardSignal(null);
}, [processBoardSignal]),
processBoardChange(null);
}, [processBoardChange]),
onmessage: useCallback(({event}: WebSocketHandlerParams<MessageEvent>) => {
const action: BoardSignal = JSON.parse(event.data);
const action: BoardChange = JSON.parse(event.data);
console.debug("[useBoardWs] Received signal:", action);
processBoardSignal(action)
}, [processBoardSignal]),
processBoardChange(action)
}, [processBoardChange]),
onerror: useCallback(({event, closeWebSocket}: WebSocketHandlerParams<Event>) => {
console.error("[useBoardWs] Encountered a WebSocket error, closing current connection:", event);

View file

@ -0,0 +1,90 @@
import {TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {TaskEditorIcon} from "@/app/[lang]/board/[board]/(page)/(edit)/(task)/TaskEditorIcon"
import {TaskEditorInput} from "@/app/[lang]/board/[board]/(page)/(edit)/(task)/TaskEditorInput"
import {taskToString} from "@/app/[lang]/board/[board]/(page)/(edit)/taskToString"
import {TaskContainer} from "@/app/[lang]/board/[board]/(page)/(task)/TaskContainer"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import {useTaskEditor} from "@/app/[lang]/board/[board]/(page)/useTaskEditor"
import cn from "classnames"
import {TFunction} from "i18next"
import {SyntheticEvent, useCallback} from "react"
import style from "./TaskEditor.module.css"
const ICON_SEQUENCE = [
TaskIcon.Bookmark,
TaskIcon.Circle,
TaskIcon.Square,
TaskIcon.Heart,
TaskIcon.Star,
TaskIcon.Sun,
TaskIcon.Moon,
TaskIcon.Eye,
TaskIcon.Hand,
TaskIcon.Handshake,
TaskIcon.FaceSmile,
TaskIcon.User,
TaskIcon.Comment,
TaskIcon.Envelope,
TaskIcon.File,
TaskIcon.PaperPlane,
TaskIcon.Building,
TaskIcon.Flag,
TaskIcon.Bell,
TaskIcon.Clock,
TaskIcon.Image,
]
export function TaskEditor({t, className, editorHook: {input, setInput, task, setTask}}: {t: TFunction, className?: string, editorHook: ReturnType<typeof useTaskEditor>}) {
const {isReady, sendRequest} = useBoardConsumer()
const nextIcon = useCallback((e: SyntheticEvent<HTMLButtonElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(![" "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
let index = ICON_SEQUENCE.indexOf(task.icon) + 1;
if(index === ICON_SEQUENCE.length) index = 0;
setTask({...task, icon: ICON_SEQUENCE[index]})
}, [task])
const submitTask = useCallback((e: SyntheticEvent<HTMLInputElement>) => {
e.preventDefault()
e.stopPropagation()
if(task.text === "") return
sendRequest({"Task": [null, task]})
setInput(taskToString({...task, text: ""}))
}, [sendRequest, task, setInput])
if(!isReady) return null
return (
<div
className={cn(className, style.taskEditorContainer)}
>
<TaskContainer
role={"form"}
importance={task.importance}
priority={task.priority}
status={TaskSimplifiedStatus.NonExistent}
onSubmit={submitTask}
>
<TaskEditorIcon
t={t}
icon={task.icon}
nextIcon={nextIcon}
/>
<TaskEditorInput
t={t}
input={input}
setInput={setInput}
/>
</TaskContainer>
</div>
)
}

View file

@ -0,0 +1,41 @@
import {TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskIconComponent} from "@/app/[lang]/board/[board]/(page)/(task)/TaskIconComponent"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import {TFunction} from "i18next"
import {SyntheticEvent} from "react"
const ICON_TO_KEY = {
[TaskIcon.Bookmark]: "taskIconBookmark",
[TaskIcon.Circle]: "taskIconCircle",
[TaskIcon.Square]: "taskIconSquare",
[TaskIcon.Heart]: "taskIconHeart",
[TaskIcon.Star]: "taskIconStar",
[TaskIcon.Sun]: "taskIconSun",
[TaskIcon.Moon]: "taskIconMoon",
[TaskIcon.Eye]: "taskIconEye",
[TaskIcon.Hand]: "taskIconHand",
[TaskIcon.Handshake]: "taskIconHandshake",
[TaskIcon.FaceSmile]: "taskIconFaceSmile",
[TaskIcon.User]: "taskIconUser",
[TaskIcon.Comment]: "taskIconComment",
[TaskIcon.Envelope]: "taskIconEnvelope",
[TaskIcon.File]: "taskIconFile",
[TaskIcon.PaperPlane]: "taskIconPaperPlane",
[TaskIcon.Building]: "taskIconBuilding",
[TaskIcon.Flag]: "taskIconFlag",
[TaskIcon.Bell]: "taskIconBell",
[TaskIcon.Clock]: "taskIconClock",
[TaskIcon.Image]: "taskIconImage",
}
export function TaskEditorIcon({t, icon, nextIcon}: {t: TFunction, icon: TaskIcon, nextIcon: (e: SyntheticEvent<HTMLButtonElement>) => void}) {
return (
<TaskIconComponent
title={t(ICON_TO_KEY[icon])}
icon={icon}
status={TaskSimplifiedStatus.NonExistent}
onInteract={nextIcon}
/>
)
}

View file

@ -0,0 +1,16 @@
import style from "@/app/[lang]/board/[board]/(page)/(edit)/(task)/TaskEditor.module.css"
import {TFunction} from "i18next"
import {ChangeEvent, Dispatch, SetStateAction} from "react"
export function TaskEditorInput({t, input, setInput}: {t: TFunction, input: string, setInput: Dispatch<SetStateAction<string>>}) {
return (
<input
className={style.taskEditorInput}
type={"text"}
placeholder={t("editorPlaceholder")}
value={input}
onChange={(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
/>
)
}

View file

@ -1,13 +1,19 @@
import {TaskEditor} from "@/app/[lang]/board/[board]/(page)/(edit)/TaskEditor"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {TaskEditor} from "@/app/[lang]/board/[board]/(page)/(edit)/(task)/TaskEditor"
import {useTaskEditor} from "@/app/[lang]/board/[board]/(page)/useTaskEditor"
import cn from "classnames"
import {TFunction} from "i18next"
import style from "./BoardEditor.module.css"
export function BoardEditor({className, lang, editorHook}: {className?: string, lang: string, editorHook: ReturnType<typeof useTaskEditor>}) {
export function BoardEditor({className, t, editorHook}: {className?: string, t: TFunction, editorHook: ReturnType<typeof useTaskEditor>}) {
const {boardState: {locked}} = useBoardConsumer()
if(locked) return null;
return (
<section className={cn(style.boardEditor, className)}>
<TaskEditor lang={lang} editorHook={editorHook}/>
<TaskEditor t={t} editorHook={editorHook}/>
</section>
)
}

View file

@ -1,49 +0,0 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {TASK_ICON_TO_FONTAWESOME_REGULAR} from "@/app/[lang]/board/[board]/(api)/(task)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {taskClassNames} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/taskClassNames"
import {useTaskEditor} from "@/app/[lang]/board/[board]/(page)/useTaskEditor"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {ChangeEvent, useCallback, useMemo, SyntheticEvent} from "react"
import style from "./TaskEditor.module.css"
export function TaskEditor({lang, className, editorHook: {input, setInput, task}}: {lang: string, className?: string, editorHook: ReturnType<typeof useTaskEditor>}) {
const {t} = useClientTranslation(lang, "board")
const {isReady, sendRequest} = useBoardConsumer()
const inputClassName = useMemo(() => taskClassNames(task), [task])
const submitTask = useCallback((e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
sendRequest({"Task": [null, task]})
setInput("")
}, [sendRequest, task, setInput])
if(!isReady) return null
return (
<div
className={cn(className, style.taskEditorContainer)}
>
<form
className={cn("panel", style.taskEditor, className, inputClassName)}
onSubmit={submitTask}
>
<div className={style.taskEditorIcon}>
<FontAwesomeIcon
icon={TASK_ICON_TO_FONTAWESOME_REGULAR[task.icon]}
size={"lg"}
/>
</div>
<input
className={style.taskEditorInput}
type={"text"}
placeholder={t("editorPlaceholder")}
value={input}
onChange={(e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value)}
/>
</form>
</div>
)
}

View file

@ -1,8 +1,9 @@
import {Task, TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {Task, TaskIcon, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import {ICON_GLYPH_RE} from "@/app/[lang]/board/[board]/(page)/(edit)/icon"
import {IMPORTANCE_GLYPH_RE} from "@/app/[lang]/board/[board]/(page)/(edit)/importance"
import {PRIORITY_GLYPH_RE} from "@/app/[lang]/board/[board]/(page)/(edit)/priority"
const VALUE_TO_TASK_IMPORTANCE = {
"1": TaskImportance.Highest,
"2": TaskImportance.High,
@ -31,11 +32,13 @@ const VALUE_TO_TASK_ICON = {
"hand": TaskIcon.Hand,
"handshake": TaskIcon.Handshake,
"facesmile": TaskIcon.FaceSmile,
"smile": TaskIcon.FaceSmile,
"user": TaskIcon.User,
"comment": TaskIcon.Comment,
"envelope": TaskIcon.Envelope,
"file": TaskIcon.File,
"paperplane": TaskIcon.PaperPlane,
"plane": TaskIcon.PaperPlane,
"building": TaskIcon.Building,
"flag": TaskIcon.Flag,
"bell": TaskIcon.Bell,
@ -61,9 +64,12 @@ export function stringToTask(text: string): Task {
return {
text,
status: TaskStatus.Unfinished,
priority,
importance,
icon,
created_on: + new Date(),
started_on: null,
completed_on: null,
journaled_on: null,
}
}

View file

@ -3,6 +3,7 @@ import {ICON_DEFAULT, ICON_GLYPH} from "@/app/[lang]/board/[board]/(page)/(edit)
import {IMPORTANCE_DEFAULT, IMPORTANCE_GLYPH} from "@/app/[lang]/board/[board]/(page)/(edit)/importance"
import {PRIORITY_DEFAULT, PRIORITY_GLYPH} from "@/app/[lang]/board/[board]/(page)/(edit)/priority"
const TASK_IMPORTANCE_TO_VALUE = {
[TaskImportance.Highest]: "1",
[TaskImportance.High]: "2",
@ -22,6 +23,12 @@ const TASK_PRIORITY_TO_VALUE = {
export function taskToString(t: Task): string {
let s = ""
if(t.icon !== ICON_DEFAULT) {
s += ICON_GLYPH
s += t.icon
s += " "
}
if(t.importance !== IMPORTANCE_DEFAULT) {
s += IMPORTANCE_GLYPH
s += TASK_IMPORTANCE_TO_VALUE[t.importance]
@ -34,12 +41,6 @@ export function taskToString(t: Task): string {
s += " "
}
if(t.icon !== ICON_DEFAULT) {
s += ICON_GLYPH
s += t.icon
s += " "
}
s += t.text
return s

View file

@ -1,43 +1,70 @@
import {BoardHeaderTitle} from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderTitle"
import {ConnectedClientsButton} from "@/app/[lang]/board/[board]/(page)/(header)/ConnectedClientsButton"
import {CycleColumningButton} from "@/app/[lang]/board/[board]/(page)/(header)/CycleColumningButton"
import {CycleGroupingButton} from "@/app/[lang]/board/[board]/(page)/(header)/CycleGroupingButton"
import {CycleSortingButton} from "@/app/[lang]/board/[board]/(page)/(header)/CycleSortingButton"
import {NavigateHomeButton} from "@/app/[lang]/board/[board]/(page)/(header)/NavigateHomeButton"
import {ConnectedClientsButton} from "@/app/[lang]/board/[board]/(page)/(header)/ConnectedClientsButton"
import {ToggleEditingButton} from "@/app/[lang]/board/[board]/(page)/(header)/ToggleEditingButton"
import {ToggleLockedButton} from "@/app/[lang]/board/[board]/(page)/(header)/ToggleLockedButton"
import {ToggleStarredButton} from "@/app/[lang]/board/[board]/(page)/(header)/ToggleStarredButton"
import {TrimButton} from "@/app/[lang]/board/[board]/(page)/(header)/TrimButton"
import {useBoardLayoutEditor} from "@/app/[lang]/board/[board]/(page)/useBoardLayoutEditor"
import {useBoardMetadataEditor} from "@/app/[lang]/board/[board]/(page)/useBoardMetadataEditor"
import style from "./BoardHeader.module.css"
import cn from "classnames"
import {TFunction} from "i18next"
import style from "./BoardHeader.module.css"
interface BoardHeaderProps {
lang: string,
t: TFunction,
className?: string,
metadataHook: ReturnType<typeof useBoardMetadataEditor>,
layoutHook: ReturnType<typeof useBoardLayoutEditor>,
}
export function BoardHeader({lang, className, metadataHook, layoutHook: {columningHook, groupingHook, sortingHook}}: BoardHeaderProps) {
export function BoardHeader({t, className, metadataHook, layoutHook: {columningHook, groupingHook, sortingHook}}: BoardHeaderProps) {
return (
<header className={cn(style.boardHeader, className)}>
<div className={cn(style.buttonsArea, style.leftButtonsArea)}>
<NavigateHomeButton lang={lang}/>
<ToggleStarredButton lang={lang}/>
<ConnectedClientsButton lang={lang}/>
<NavigateHomeButton
t={t}
/>
<ToggleStarredButton
t={t}
/>
<ToggleLockedButton
t={t}
/>
<ConnectedClientsButton
t={t}
/>
</div>
<BoardHeaderTitle
lang={lang}
t={t}
className={style.titleArea}
editorHook={metadataHook}
/>
<div className={cn(style.buttonsArea, style.rightButtonsArea)}>
<ToggleEditingButton lang={lang} metadataHook={metadataHook}/>
<CycleColumningButton lang={lang} value={columningHook.value} next={columningHook.next}/>
<CycleGroupingButton lang={lang} next={groupingHook.next}/>
<CycleSortingButton lang={lang} next={sortingHook.next}/>
<TrimButton
t={t}
/>
<ToggleEditingButton
t={t}
metadataHook={metadataHook}
/>
<CycleColumningButton
t={t}
value={columningHook.value} next={columningHook.next}
/>
<CycleGroupingButton
t={t}
next={groupingHook.next}
/>
<CycleSortingButton
t={t}
next={sortingHook.next}
/>
</div>
</header>
)

View file

@ -1,14 +1,13 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {usePageTitleSetter} from "@/app/(utils)/usePageTitleSetter"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderTitle.module.css"
import {useBoardMetadataEditor} from "@/app/[lang]/board/[board]/(page)/useBoardMetadataEditor"
import cn from "classnames"
import {TFunction} from "i18next"
import {useMemo} from "react"
export function BoardHeaderTitle({lang, className, editorHook}: {lang: string, className?: string, editorHook: ReturnType<typeof useBoardMetadataEditor>}) {
const {t} = useClientTranslation(lang, "board")
export function BoardHeaderTitle({t, className, editorHook}: {t: TFunction, className?: string, editorHook: ReturnType<typeof useBoardMetadataEditor>}) {
const {isReady, boardState: {title: titleFromState}} = useBoardConsumer()
const pageTitle = useMemo(() => {

View file

@ -1,25 +1,24 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import {faUsers} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
export function ConnectedClientsButton({lang}: {lang: string}) {
const {isReady, boardState: {connectedClients}} = useBoardConsumer()
const {t} = useClientTranslation(lang, "board")
export function ConnectedClientsButton({t}: {t: TFunction}) {
const {isReady, boardState: {clients}} = useBoardConsumer()
if(!isReady) return null;
return (
<div
title={t("privacyButtonTitle")}
className={cn(style.block, style.doubleBlock)}
className={cn(style.block, style.singleBlock)}
>
<FontAwesomeIcon icon={faUsers}/>
<FontAwesomeIcon icon={faUsers} size={"2xs"}/>
&nbsp;
{connectedClients.length}
{clients.length}
</div>
)
}

View file

@ -1,14 +1,13 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import cn from "classnames"
import {COLUMNING_MODE_TO_ICON, ColumningMode} from "../(view)/(columning)"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
import {COLUMNING_MODE_TO_ICON, ColumningMode} from "../(view)/(columning)"
export function CycleColumningButton({lang, value, next}: {lang: string, value: ColumningMode, next: () => void}) {
export function CycleColumningButton({t, value, next}: {t: TFunction, value: ColumningMode, next: () => void}) {
const {isReady} = useBoardConsumer()
const {t} = useClientTranslation(lang, "board")
if(!isReady) return null;

View file

@ -1,14 +1,13 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import {faObjectGroup} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
export function CycleGroupingButton({lang, next}: {lang: string, next: () => void}) {
export function CycleGroupingButton({t, next}: {t: TFunction, next: () => void}) {
const {isReady} = useBoardConsumer()
const {t} = useClientTranslation(lang, "board")
if(!isReady) return null;

View file

@ -1,14 +1,13 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import {faArrowDownWideShort} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
export function CycleSortingButton({lang, next}: {lang: string, next: () => void}) {
export function CycleSortingButton({t, next}: {t: TFunction, next: () => void}) {
const {isReady} = useBoardConsumer()
const {t} = useClientTranslation(lang, "board")
if(!isReady) return null;

View file

@ -1,14 +1,13 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {faHouse} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
import {useRouter} from "next/navigation"
import {useCallback} from "react"
export function NavigateHomeButton({lang}: {lang: string}) {
const {t} = useClientTranslation(lang, "board")
export function NavigateHomeButton({t}: {t: TFunction}) {
const router = useRouter()
const goHome = useCallback(() => router.push("/"), [router])

View file

@ -1,17 +1,17 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {useBoardMetadataEditor} from "@/app/[lang]/board/[board]/(page)/useBoardMetadataEditor"
import {faFloppyDisk, faPencil} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
export function ToggleEditingButton({lang, metadataHook}: {lang: string, metadataHook: ReturnType<typeof useBoardMetadataEditor>}) {
const {t} = useClientTranslation(lang, "board")
const {isReady} = useBoardConsumer()
export function ToggleEditingButton({t, metadataHook}: {t: TFunction, metadataHook: ReturnType<typeof useBoardMetadataEditor>}) {
const {isReady, boardState: {locked}} = useBoardConsumer()
if(!isReady) return null;
if(locked) return null;
return (
<button

View file

@ -0,0 +1,31 @@
import {LockBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {faLock, faLockOpen} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
import {useCallback} from "react"
export function ToggleLockedButton({t}: {t: TFunction}) {
const {isReady, boardState: {locked}, sendRequest} = useBoardConsumer()
const toggleLock = useCallback(() => {
const request: LockBoardRequest = {"Lock": !locked}
sendRequest(request)
}, [locked, sendRequest])
if(!isReady) return null;
// FIXME
return (
<button
title={locked ? t("lockButtonTitle") : t("unlockButtonTitle")}
onClick={toggleLock}
className={cn(style.block, style.singleBlock)}
>
<FontAwesomeIcon icon={locked ? faLock : faLockOpen}/>
</button>
)
}

View file

@ -1,4 +1,3 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useStarredConsumer} from "@/app/[lang]/(layout)/(contextStarred)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
@ -6,10 +5,10 @@ import {faStar as faStarRegular} from "@fortawesome/free-regular-svg-icons"
import {faStar as faStarSolid} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
export function ToggleStarredButton({lang}: {lang: string}) {
const {t} = useClientTranslation(lang, "board")
export function ToggleStarredButton({t}: {t: TFunction}) {
const {boardName} = useBoardConsumer()
const {isStarred, toggleStarred} = useStarredConsumer()
const thisIsStarred = isStarred(boardName)

View file

@ -0,0 +1,31 @@
import {TrimBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import style from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderButtons.module.css"
import {faScissors} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {TFunction} from "i18next"
import {useCallback} from "react"
export function TrimButton({t}: {t: TFunction}) {
const {isReady, boardState: {locked}, sendRequest} = useBoardConsumer()
const requestTrim = useCallback(() => {
const request: TrimBoardRequest = {"Trim": null}
sendRequest(request)
}, [locked, sendRequest])
if(!isReady) return null;
if(!locked) return null;
return (
<button
title={t("trimButtonTitle")}
onClick={requestTrim}
className={cn(style.block, style.singleBlock)}
>
<FontAwesomeIcon icon={faScissors}/>
</button>
)
}

View file

@ -0,0 +1,7 @@
.taskActions {
display: flex;
gap: 4px;
flex-grow: 0;
flex-shrink: 1;
}

View file

@ -0,0 +1,18 @@
import cn from "classnames"
import {ReactNode} from "react"
import style from "./TaskActions.module.css"
type TaskActionsProps = {
className?: string,
children: ReactNode,
}
export function TaskActions({className, children}: TaskActionsProps) {
return (
<div className={cn(style.taskActions, className)}>
{children}
</div>
)
}

View file

@ -0,0 +1,3 @@
.taskButton {
}

View file

@ -0,0 +1,35 @@
import {IconDefinition} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {SyntheticEvent} from "react"
import style from "./TaskButton.module.css"
type TaskButtonProps = {
className?: string,
title: string,
icon: IconDefinition,
onInteract?: (e: SyntheticEvent<HTMLButtonElement>) => void,
}
export function TaskButton({className, title, icon, onInteract}: TaskButtonProps) {
return (
<button
className={cn({
"fade": !onInteract,
[style.taskButton]: true,
}, className)}
title={title}
disabled={!onInteract}
onPointerDown={onInteract}
onKeyDown={onInteract}
tabIndex={0}
>
<FontAwesomeIcon
size={"sm"}
icon={icon}
/>
</button>
)
}

View file

@ -0,0 +1,140 @@
.taskContainer {
flex-direction: row;
align-items: center;
min-width: 240px;
min-height: 48px;
}
.taskImportanceHighest {
--bhsl-current-hue: 18deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 75%;
font-weight: 800;
}
.taskImportanceHigh {
--bhsl-current-hue: 250deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 78%;
font-weight: 600;
}
.taskImportanceNormal {
--bhsl-current-hue: 212deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 81%;
font-weight: 400;
}
.taskImportanceLow {
--bhsl-current-hue: 160deg;
--bhsl-current-saturation: 45%;
--bhsl-current-lightness: 60%;
font-weight: 300;
}
.taskImportanceLowest {
--bhsl-current-hue: 120deg;
--bhsl-current-saturation: 30%;
--bhsl-current-lightness: 45%;
font-weight: 200;
}
.taskPriorityHighest {
border: 4px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.45);
padding: 4px;
}
.taskPriorityHigh {
border: 3px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.25);
padding: 5px;
}
.taskPriorityNormal {
border: 2px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.15);
padding: 6px;
}
.taskPriorityLow {
border: 1px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.15);
padding: 7px;
}
.taskPriorityLowest {
border: 0;
padding: 8px;
}
@keyframes inProgress {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.taskStatusNonExistent {
}
.taskStatusInProgress :global(.taskDescription) {
animation-name: inProgress;
animation-delay: 0s;
animation-direction: normal;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
.taskStatusComplete :global(.taskDescription) {
opacity: 0.4;
text-decoration: 2px currentColor solid line-through;
}
.taskStatusUnfinishedColumnHeader {
}
.taskStatusInProgressColumnHeader {
animation-name: inProgress;
animation-delay: 0s;
animation-direction: normal;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
.taskStatusCompleteColumnHeader {
opacity: 0.4;
text-decoration: 2px currentColor solid line-through;
}
.taskStatusJournaled {
}
.taskPriorityHighestColumnHeader {
text-decoration: underline 4px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 1.0);
}
.taskPriorityHighColumnHeader {
text-decoration: underline 3px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.75);
}
.taskPriorityNormalColumnHeader {
text-decoration: underline 2px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.50);
}
.taskPriorityLowColumnHeader {
text-decoration: underline 1px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.25);
}
.taskPriorityLowestColumnHeader {
text-decoration: none;
}

View file

@ -0,0 +1,55 @@
import {TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import cn from "classnames"
import {ComponentPropsWithoutRef} from "react"
import style from "./TaskContainer.module.css"
export type TaskContainerProps = {
className?: string,
role: "article" | "form",
importance: TaskImportance,
priority: TaskPriority,
status: TaskSimplifiedStatus,
} & ComponentPropsWithoutRef<"article">
export function TaskContainer({role, className, importance, priority, status, ...props}: TaskContainerProps) {
const fullProps = {
className: cn({
"panel": true,
[style.taskContainer]: true,
[style.taskContainerClickable]: true,
[style.taskImportanceHighest]: importance === TaskImportance.Highest,
[style.taskImportanceHigh]: importance === TaskImportance.High,
[style.taskImportanceNormal]: importance === TaskImportance.Normal,
[style.taskImportanceLow]: importance === TaskImportance.Low,
[style.taskImportanceLowest]: importance === TaskImportance.Lowest,
[style.taskPriorityHighest]: priority === TaskPriority.Highest,
[style.taskPriorityHigh]: priority === TaskPriority.High,
[style.taskPriorityNormal]: priority === TaskPriority.Normal,
[style.taskPriorityLow]: priority === TaskPriority.Low,
[style.taskPriorityLowest]: priority === TaskPriority.Lowest,
[style.taskStatusNonExistent]: status === TaskSimplifiedStatus.NonExistent,
[style.taskStatusUnfinished]: status === TaskSimplifiedStatus.Unfinished,
[style.taskStatusInProgress]: status === TaskSimplifiedStatus.InProgress,
[style.taskStatusComplete]: status === TaskSimplifiedStatus.Complete,
[style.taskStatusJournaled]: status === TaskSimplifiedStatus.Journaled,
}, className),
...props,
}
if(role === "article") {
return (
<article {...fullProps}/>
)
}
else if(role === "form") {
return (
<form {...fullProps}/>
)
}
else {
return null
}
}

View file

@ -0,0 +1,3 @@
.taskDebug {
font-size: x-small;
}

View file

@ -0,0 +1,22 @@
import cn from "classnames"
import {useMemo} from "react"
import style from "./TaskDebug.module.css"
type TaskDebugProps = {
className?: string,
task: object,
}
export function TaskDebug({className, task}: TaskDebugProps) {
const json = useMemo(() => JSON.stringify(task, undefined, 2), [task])
return (
<pre className={cn(style.taskDebug, className)}>
<code lang={"json"}>
{json}
</code>
</pre>
)
}

View file

@ -0,0 +1,4 @@
.taskDescription {
border-radius: var(--b-border-radius);
flex-grow: 1;
}

View file

@ -0,0 +1,19 @@
import cn from "classnames"
import style from "./TaskDescription.module.css"
type TaskDescriptionProps = {
className?: string,
text: string,
}
export function TaskDescription({className, text}: TaskDescriptionProps) {
return (
<div className={cn({
"taskDescription": true,
[style.taskDescription]: true,
}, className)} tabIndex={0}>
{text}
</div>
)
}

View file

@ -0,0 +1,34 @@
.taskIconComponent {
width: 28px;
height: 28px;
padding: 0;
padding-inline: 0;
border-width: 0;
background: transparent;
display: flex;
flex-shrink: 0;
flex-direction: column;
justify-content: center;
cursor: unset;
}
.taskIconComponentClickable {
cursor: pointer;
transition-property: filter;
transition-duration: 0.5s;
}
.taskIconComponentClickable:hover {
filter: drop-shadow(0 0 4px currentColor);
transition-duration: 0s;
}
.taskIconComponentClickable:active {
filter: drop-shadow(0 0 6px currentColor) drop-shadow(0 0 6px currentColor);
transition-duration: 0s;
}

View file

@ -0,0 +1,40 @@
import {TASK_ICON_TO_FONTAWESOME_REGULAR, TASK_ICON_TO_FONTAWESOME_SOLID, TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {SyntheticEvent} from "react"
import style from "./TaskIconComponent.module.css"
type TaskIconProps = {
className?: string,
title: string,
icon: TaskIcon,
status: TaskSimplifiedStatus,
onInteract?: (e: SyntheticEvent<HTMLButtonElement>) => void,
}
export function TaskIconComponent({className, title, icon, status, onInteract}: TaskIconProps) {
const clickable = !!onInteract;
return (
<button
className={cn({
[style.taskIconComponent]: true,
[style.taskIconComponentClickable]: clickable,
}, className)}
type={"button"}
title={title}
onClick={onInteract}
onKeyDown={onInteract}
tabIndex={clickable ? 0 : -1}
>
<FontAwesomeIcon
size={"lg"}
icon={[TaskSimplifiedStatus.Complete, TaskSimplifiedStatus.Journaled].includes(status) ? TASK_ICON_TO_FONTAWESOME_SOLID[icon] : TASK_ICON_TO_FONTAWESOME_REGULAR[icon]}
beatFade={status === TaskSimplifiedStatus.InProgress}
/>
</button>
)
}

View file

@ -0,0 +1,10 @@
/**
* A simplification of the `started_on`, `completed_on` and `journaled_on` fields of a {@link Task}.
*/
export enum TaskSimplifiedStatus {
NonExistent = -1,
Unfinished = 0,
InProgress = 1,
Complete = 2,
Journaled = 3,
}

View file

@ -3,4 +3,5 @@ export enum GroupingMode {
ByPriority,
ByStatus,
ByIcon,
Journal,
}

View file

@ -0,0 +1,7 @@
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
export type TaskGroup<K> = {
k: K,
tasks: TaskWithId[],
}

View file

@ -1,6 +1,7 @@
import {TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskIcon, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import {GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/GroupingMode"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskGroup"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/TaskGroup"
const TASK_IMPORTANCE_TO_VALUE = {
[TaskImportance.Highest]: 1,
@ -19,9 +20,10 @@ const TASK_PRIORITY_TO_VALUE = {
}
const TASK_STATUS_TO_VALUE = {
[TaskStatus.Unfinished]: 1,
[TaskStatus.InProgress]: 2,
[TaskStatus.Complete]: 3,
"Journaled": 3,
"Complete": 2,
"InProgress": 1,
"Unfinished": 0,
}
const TASK_ICON_TO_VALUE = {
@ -55,10 +57,14 @@ export const GROUPING_MODE_TO_GROUP_SORTER_FUNCTION = {
[GroupingMode.ByPriority]: function sortGroupsByPriority(a: TaskGroup<TaskPriority>, b: TaskGroup<TaskPriority>): number {
return TASK_PRIORITY_TO_VALUE[a.k] - TASK_PRIORITY_TO_VALUE[b.k]
},
[GroupingMode.ByStatus]: function sortGroupsByStatus(a: TaskGroup<TaskStatus>, b: TaskGroup<TaskStatus>): number {
return TASK_STATUS_TO_VALUE[a.k] - TASK_STATUS_TO_VALUE[b.k]
[GroupingMode.ByStatus]: function sortGroupsByStatus(a: TaskGroup<string>, b: TaskGroup<string>): number {
// @ts-ignore
return TASK_STATUS_TO_VALUE[a.k] - TASK_STATUS_TO_VALUE[b.k]
},
[GroupingMode.ByIcon]: function sortGroupsByIcon(a: TaskGroup<TaskIcon>, b: TaskGroup<TaskIcon>): number {
return TASK_ICON_TO_VALUE[a.k] - TASK_ICON_TO_VALUE[b.k]
},
[GroupingMode.Journal]: function sortGroupsAlphabetically(a: TaskGroup<TaskIcon>, b: TaskGroup<TaskIcon>): number {
return b.k.localeCompare(a.k)
}
}

View file

@ -1,19 +1,32 @@
import {TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskIcon, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/GroupingMode"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
export const GROUPING_MODE_TO_TASK_GROUPER_FUNCTION = {
[GroupingMode.ByImportance]: function groupTasksByImportance(t: TaskWithId): TaskImportance {
[GroupingMode.ByImportance]: function groupTasksByImportance(t: TaskWithId): TaskImportance | null {
if(t[1].journaled_on) return null
return t[1].importance
},
[GroupingMode.ByPriority]: function groupTasksByPriority(t: TaskWithId): TaskPriority {
[GroupingMode.ByPriority]: function groupTasksByPriority(t: TaskWithId): TaskPriority | null {
if(t[1].journaled_on) return null
return t[1].priority
},
[GroupingMode.ByStatus]: function groupTasksByStatus(t: TaskWithId): TaskStatus {
return t[1].status
[GroupingMode.ByStatus]: function groupTasksByStatus(t: TaskWithId): string | null {
if(t[1].journaled_on) return null
else if(t[1].completed_on) return "Complete"
else if(t[1].started_on) return "InProgress"
else return "Unfinished"
},
[GroupingMode.ByIcon]: function groupTasksByIcon(t: TaskWithId): TaskIcon {
[GroupingMode.ByIcon]: function groupTasksByIcon(t: TaskWithId): TaskIcon | null {
if(t[1].journaled_on) return null
return t[1].icon
},
[GroupingMode.Journal]: function groupTasksByCompletitionDate(t: TaskWithId): string | null {
if(!t[1].journaled_on) return null
let date;
if(!t[1].completed_on) date = new Date(0)
else date = new Date(t[1].completed_on)
return date.toISOString().split("T")[0]
}
}

View file

@ -1,120 +1,112 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {TASK_ICON_TO_FONTAWESOME_REGULAR, TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TASK_ICON_TO_FONTAWESOME_REGULAR, TaskIcon, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import style from "@/app/[lang]/board/[board]/(page)/(task)/TaskContainer.module.css"
import {GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/GroupingMode"
import taskImportanceStyle from "@/app/[lang]/board/[board]/(page)/(view)/(task)/taskImportance.module.css"
import taskPriorityStyle from "@/app/[lang]/board/[board]/(page)/(view)/(task)/taskPriority.module.css"
import taskStatusStyle from "@/app/[lang]/board/[board]/(page)/(view)/(task)/taskStatus.module.css"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import {TFunction} from "i18next"
import {ReactNode} from "react"
export interface TitleComponentProps {
lang: string,
t: TFunction,
k: any,
}
export const GROUPING_MODE_TO_TITLE_COMPONENT: {[key in GroupingMode]: (t: TitleComponentProps) => ReactNode} = {
[GroupingMode.ByImportance]: function TitleFromImportance({lang, k}: { lang: string, k: TaskImportance }): ReactNode {
const {t} = useClientTranslation(lang, "board")
[GroupingMode.ByImportance]: function TitleFromImportance({t, k}: { t: TFunction, k: TaskImportance }): ReactNode {
switch(k) {
case TaskImportance.Highest:
return (
<span className={taskImportanceStyle.taskImportanceHighest}>
{t("columnHeaderTaskImportanceHighest")}
<span className={style.taskImportanceHighest}>
{t("taskImportanceHighest")}
</span>
)
case TaskImportance.High:
return (
<span className={taskImportanceStyle.taskImportanceHigh}>
{t("columnHeaderTaskImportanceHigh")}
<span className={style.taskImportanceHigh}>
{t("taskImportanceHigh")}
</span>
)
case TaskImportance.Normal:
return (
<span className={taskImportanceStyle.taskImportanceNormal}>
{t("columnHeaderTaskImportanceNormal")}
<span className={style.taskImportanceNormal}>
{t("taskImportanceNormal")}
</span>
)
case TaskImportance.Low:
return (
<span className={taskImportanceStyle.taskImportanceLow}>
{t("columnHeaderTaskImportanceLow")}
<span className={style.taskImportanceLow}>
{t("taskImportanceLow")}
</span>
)
case TaskImportance.Lowest:
return (
<span className={taskImportanceStyle.taskImportanceLowest}>
{t("columnHeaderTaskImportanceLowest")}
<span className={style.taskImportanceLowest}>
{t("taskImportanceLowest")}
</span>
)
}
},
[GroupingMode.ByPriority]: function TitleFromPriority({lang, k}: { lang: string, k: TaskPriority }): ReactNode {
const {t} = useClientTranslation(lang, "board")
[GroupingMode.ByPriority]: function TitleFromPriority({t, k}: { t: TFunction, k: TaskPriority }): ReactNode {
switch(k) {
case TaskPriority.Highest:
return (
<span className={taskPriorityStyle.taskPriorityHighestColumnHeader}>
{t("columnHeaderTaskPriorityHighest")}
<span className={style.taskPriorityHighestColumnHeader}>
{t("taskPriorityHighest")}
</span>
)
case TaskPriority.High:
return (
<span className={taskPriorityStyle.taskPriorityHighColumnHeader}>
{t("columnHeaderTaskPriorityHigh")}
<span className={style.taskPriorityHighColumnHeader}>
{t("taskPriorityHigh")}
</span>
)
case TaskPriority.Normal:
return (
<span className={taskPriorityStyle.taskPriorityNormalColumnHeader}>
{t("columnHeaderTaskPriorityNormal")}
<span className={style.taskPriorityNormalColumnHeader}>
{t("taskPriorityNormal")}
</span>
)
case TaskPriority.Low:
return (
<span className={taskPriorityStyle.taskPriorityLowColumnHeader}>
{t("columnHeaderTaskPriorityLow")}
<span className={style.taskPriorityLowColumnHeader}>
{t("taskPriorityLow")}
</span>
)
case TaskPriority.Lowest:
return (
<span className={taskPriorityStyle.taskPriorityLowestColumnHeader}>
{t("columnHeaderTaskPriorityLowest")}
<span className={style.taskPriorityLowestColumnHeader}>
{t("taskPriorityLowest")}
</span>
)
}
},
[GroupingMode.ByStatus]: function TitleFromStatus({lang, k}: { lang: string, k: TaskStatus }): ReactNode {
const {t} = useClientTranslation(lang, "board")
[GroupingMode.ByStatus]: function TitleFromStatus({t, k}: { t: TFunction, k: string }): ReactNode {
switch(k) {
case TaskStatus.Unfinished:
case "Unfinished":
return (
<span className={taskStatusStyle.taskStatusUnfinishedColumnHeader}>
{t("columnHeaderTaskStatusUnfinished")}
<span className={style.taskStatusUnfinishedColumnHeader}>
{t("taskStatusUnfinished")}
</span>
)
case TaskStatus.InProgress:
case "InProgress":
return (
<span className={taskStatusStyle.taskStatusInProgressColumnHeader}>
{t("columnHeaderTaskStatusInProgress")}
<span className={style.taskStatusInProgressColumnHeader}>
{t("taskStatusInProgress")}
</span>
)
case TaskStatus.Complete:
case "Complete":
return (
<span className={taskStatusStyle.taskStatusCompleteColumnHeader}>
{t("columnHeaderTaskStatusComplete")}
<span className={style.taskStatusCompleteColumnHeader}>
{t("taskStatusComplete")}
</span>
)
}
},
[GroupingMode.ByIcon]: function TitleFromIcon({k}: { lang: string, k: TaskIcon }): ReactNode {
[GroupingMode.ByIcon]: function TitleFromIcon({k}: { t: TFunction, k: TaskIcon }): ReactNode {
return (
<span>
<FontAwesomeIcon icon={TASK_ICON_TO_FONTAWESOME_REGULAR[k]}/>
@ -123,4 +115,12 @@ export const GROUPING_MODE_TO_TITLE_COMPONENT: {[key in GroupingMode]: (t: Title
</span>
)
},
[GroupingMode.Journal]: function TitleFromDatetime({k}: { t: TFunction, k: string }): ReactNode {
return (
<time dateTime={k}>
{k}
</time>
)
}
}

View file

@ -4,4 +4,5 @@ export enum SortingMode {
ByIcon,
ByText,
ByStatus,
ByCreation,
}

View file

@ -1,6 +1,6 @@
import {TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskIcon, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {SortingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)/SortingMode"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
const TASK_IMPORTANCE_TO_VALUE = {
@ -19,12 +19,6 @@ const TASK_PRIORITY_TO_VALUE = {
[TaskPriority.Lowest]: 5,
}
const TASK_STATUS_TO_VALUE = {
[TaskStatus.Unfinished]: 2,
[TaskStatus.InProgress]: 1,
[TaskStatus.Complete]: 3,
}
const TASK_ICON_TO_VALUE = {
[TaskIcon.Bookmark]: 1,
[TaskIcon.Circle]: 2,
@ -76,6 +70,27 @@ const SORTING_MODE_TO_SORTING_FUNCTION = {
return TASK_PRIORITY_TO_VALUE[a[1].priority] - TASK_PRIORITY_TO_VALUE[b[1].priority]
},
[SortingMode.ByStatus]: function sortTasksByStatus(a: TaskWithId, b: TaskWithId) {
return TASK_STATUS_TO_VALUE[a[1].status] - TASK_STATUS_TO_VALUE[b[1].status]
}
if(a[1].journaled_on && !b[1].journaled_on) return 1;
if(!a[1].journaled_on && b[1].journaled_on) return -1;
if(a[1].completed_on && !b[1].completed_on) return 1;
if(!a[1].completed_on && b[1].completed_on) return -1;
if(a[1].started_on && !b[1].started_on) return -1;
if(!a[1].started_on && b[1].started_on) return 1;
// @ts-ignore
const journaled_on = a[1].journaled_on - b[1].journaled_on
if(journaled_on) return journaled_on
// @ts-ignore
const completed_on = a[1].completed_on - b[1].completed_on
if(completed_on) return completed_on
// @ts-ignore
const started_on = a[1].started_on - b[1].started_on
if(started_on) return started_on
return 0
},
[SortingMode.ByCreation]: function sortTasksByCreation(a: TaskWithId, b: TaskWithId) {
// @ts-ignore
return a[1].created_on - b[1].created_on
},
}

View file

@ -1,7 +0,0 @@
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
export type TaskGroup<K> = {
k: K,
tasks: TaskWithId[],
}

View file

@ -0,0 +1,151 @@
import {ModifyTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {TaskActions} from "@/app/[lang]/board/[board]/(page)/(task)/TaskActions"
import {TaskContainer} from "@/app/[lang]/board/[board]/(page)/(task)/TaskContainer"
import {TaskDescription} from "@/app/[lang]/board/[board]/(page)/(task)/TaskDescription"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {TaskViewerIcon} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskViewerIcon"
import {TaskViewerJournalButton} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskViewerJournalButton"
import {TaskViewerRecreateButton} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskViewerRecreateButton"
import {TFunction} from "i18next"
import {Dispatch, KeyboardEvent, PointerEvent, SetStateAction, SyntheticEvent, useCallback, useState} from "react"
export function TaskViewer({t, taskWithId: [id, task], setEditorInput}: {t: TFunction, taskWithId: TaskWithId, setEditorInput: Dispatch<SetStateAction<string>>}) {
const [isFlipped, setFlipped] = useState<boolean>(false)
const {sendRequest, boardState: {locked}} = useBoardConsumer()
const toggleFlipOnKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
setFlipped(prev => !prev)
},
[]
)
const flipOnPointerEnter = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if(!["mouse", "pen"].includes(e.pointerType)) {
return
}
e.preventDefault()
e.stopPropagation()
setFlipped(true)
},
[]
)
const flopOnPointerLeave = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if(!["mouse", "pen"].includes(e.pointerType)) {
return
}
e.preventDefault()
e.stopPropagation()
setFlipped(false)
},
[]
)
const toggleFlipOnTouch = useCallback(
(e: PointerEvent<HTMLDivElement>) => {
if(!["", "touch"].includes(e.pointerType)) {
return
}
e.preventDefault()
e.stopPropagation()
setFlipped(prev => !prev)
},
[]
)
const toggleStatus = useCallback((e: SyntheticEvent<HTMLButtonElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(![" "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
let request: ModifyTaskBoardRequest
if(!task.started_on) {
request = {"Task": [id, {...task, started_on: + new Date()}]}
}
else if(!task.completed_on) {
request = {"Task": [id, {...task, completed_on: + new Date()}]}
}
else {
request = {"Task": [id, {...task, started_on: null, completed_on: null}]}
}
sendRequest(request)
}, [id, task, locked, sendRequest])
let status = TaskSimplifiedStatus.Unfinished
if(task.started_on) status = TaskSimplifiedStatus.InProgress
if(task.completed_on) status = TaskSimplifiedStatus.Complete
if(task.journaled_on) status = TaskSimplifiedStatus.Journaled
let sideElements;
if(!isFlipped) {
sideElements = null;
}
else {
let goAwayButton
if(status < TaskSimplifiedStatus.Complete) {
goAwayButton = (
<TaskViewerRecreateButton
t={t}
taskWithId={[id, task]}
setEditorInput={setEditorInput}
/>
)
}
else {
goAwayButton = (
<TaskViewerJournalButton
t={t}
taskWithId={[id, task]}
/>
)
}
sideElements = (
<TaskActions>
{goAwayButton}
</TaskActions>
)
}
// noinspection PointlessBooleanExpressionJS
return (
<TaskContainer
role={"article"}
importance={task.importance}
priority={task.priority}
status={status}
onKeyDown={toggleFlipOnKeyDown}
onPointerEnter={flipOnPointerEnter}
onPointerLeave={flopOnPointerLeave}
onPointerDown={toggleFlipOnTouch}
>
<TaskViewerIcon
t={t}
icon={task.icon}
status={status}
onInteract={status === TaskSimplifiedStatus.Journaled ? undefined : toggleStatus}
/>
<TaskDescription
text={task.text}
/>
{sideElements}
</TaskContainer>
)
}

View file

@ -0,0 +1,31 @@
import {TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskIconComponent} from "@/app/[lang]/board/[board]/(page)/(task)/TaskIconComponent"
import {TaskSimplifiedStatus} from "@/app/[lang]/board/[board]/(page)/(task)/TaskSimplifiedStatus"
import {TFunction} from "i18next"
const TASK_STATUS_TO_I18N_KEY: {[key in TaskSimplifiedStatus]: string} = {
[TaskSimplifiedStatus.NonExistent]: "taskStatusNonExistent",
[TaskSimplifiedStatus.Unfinished]: "taskStatusUnfinished",
[TaskSimplifiedStatus.InProgress]: "taskStatusInProgress",
[TaskSimplifiedStatus.Complete]: "taskStatusComplete",
[TaskSimplifiedStatus.Journaled]: "taskStatusJournaled",
}
export type TaskViewerIconProps = {
t: TFunction,
status: TaskSimplifiedStatus,
icon: TaskIcon,
onInteract?: Parameters<typeof TaskIconComponent>[0]["onInteract"]
}
export function TaskViewerIcon({t, status, icon, onInteract}: TaskViewerIconProps) {
return (
<TaskIconComponent
title={t(TASK_STATUS_TO_I18N_KEY[status])}
status={status}
icon={icon}
onInteract={onInteract}
/>
)
}

View file

@ -0,0 +1,32 @@
import {UpdateTaskBoardChange} from "@/app/[lang]/board/[board]/(api)/(change)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {TaskButton} from "@/app/[lang]/board/[board]/(page)/(task)/TaskButton"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {faBookBookmark} from "@fortawesome/free-solid-svg-icons"
import {TFunction} from "i18next"
import {SyntheticEvent, useCallback} from "react"
export function TaskViewerJournalButton({t, taskWithId: [id, task]}: {t: TFunction, taskWithId: TaskWithId}) {
const {sendRequest, boardState: {locked}} = useBoardConsumer()
const toggleJournalTask = useCallback((e: SyntheticEvent<HTMLButtonElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
const request: UpdateTaskBoardChange = {"Task": [id, {...task, journaled_on: task.journaled_on ? null : + new Date()}]};
sendRequest(request)
}, [task, sendRequest])
return (
<TaskButton
title={t("taskButtonJournal")}
icon={faBookBookmark}
onInteract={locked ? undefined : toggleJournalTask}
/>
)
}

View file

@ -0,0 +1,41 @@
import {DeleteTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {taskToString} from "@/app/[lang]/board/[board]/(page)/(edit)/taskToString"
import {TaskButton} from "@/app/[lang]/board/[board]/(page)/(task)/TaskButton"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {faTrashArrowUp} from "@fortawesome/free-solid-svg-icons"
import {TFunction} from "i18next"
import {Dispatch, SetStateAction, SyntheticEvent, useCallback} from "react"
export type TaskViewerRecreateButtonProps = {
t: TFunction,
taskWithId: TaskWithId,
setEditorInput: Dispatch<SetStateAction<string>>,
}
export function TaskViewerRecreateButton({t, taskWithId: [id, task], setEditorInput}: TaskViewerRecreateButtonProps) {
const {sendRequest, boardState: {locked}} = useBoardConsumer()
const recreateTask = useCallback((e: SyntheticEvent<HTMLButtonElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
setEditorInput(taskToString(task))
const request: DeleteTaskBoardRequest = {"Task": [id, null]};
sendRequest(request)
}, [task, setEditorInput, sendRequest])
return (
<TaskButton
title={t("taskButtonRecreate")}
icon={faTrashArrowUp}
onInteract={locked ? undefined : recreateTask}
/>
)
}

View file

@ -0,0 +1,12 @@
.taskViewerSide {
}
.taskViewerSideVisible {
display: flex;
flex-direction: row;
}
.taskViewerSideHidden {
display: none;
}

View file

@ -0,0 +1,16 @@
import cn from "classnames"
import {ReactNode} from "react"
import style from "./TaskViewerSide.module.css"
export function TaskViewerSide({visible, children}: {visible: boolean, children: ReactNode}) {
return (
<div className={cn({
[style.taskViewerSide]: true,
[style.taskViewerSideVisible]: visible,
[style.taskViewerSideHidden]: !visible,
})}>
{children}
</div>
)
}

View file

@ -1,26 +0,0 @@
import {Task, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import classNames from "classnames"
import importanceStyle from "./taskImportance.module.css"
import priorityStyle from "./taskPriority.module.css"
import statusStyle from "./taskStatus.module.css"
export function taskClassNames(t: Task) {
return classNames({
[importanceStyle.taskImportanceHighest]: t.importance === TaskImportance.Highest,
[importanceStyle.taskImportanceHigh]: t.importance === TaskImportance.High,
[importanceStyle.taskImportanceNormal]: t.importance === TaskImportance.Normal,
[importanceStyle.taskImportanceLow]: t.importance === TaskImportance.Low,
[importanceStyle.taskImportanceLowest]: t.importance === TaskImportance.Lowest,
[priorityStyle.taskPriorityHighest]: t.priority === TaskPriority.Highest,
[priorityStyle.taskPriorityHigh]: t.priority === TaskPriority.High,
[priorityStyle.taskPriorityNormal]: t.priority === TaskPriority.Normal,
[priorityStyle.taskPriorityLow]: t.priority === TaskPriority.Low,
[priorityStyle.taskPriorityLowest]: t.priority === TaskPriority.Lowest,
[statusStyle.taskStatusUnfinished]: t.status === TaskStatus.Unfinished,
[statusStyle.taskStatusInProgress]: t.status === TaskStatus.InProgress,
[statusStyle.taskStatusComplete]: t.status === TaskStatus.Complete,
})
}

View file

@ -1,39 +0,0 @@
.taskImportanceHighest {
--bhsl-current-hue: 18deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 75%;
font-weight: 800;
}
.taskImportanceHigh {
--bhsl-current-hue: 250deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 78%;
font-weight: 600;
}
.taskImportanceNormal {
--bhsl-current-hue: 212deg;
--bhsl-current-saturation: 100%;
--bhsl-current-lightness: 81%;
font-weight: 400;
}
.taskImportanceLow {
--bhsl-current-hue: 160deg;
--bhsl-current-saturation: 45%;
--bhsl-current-lightness: 60%;
font-weight: 300;
}
.taskImportanceLowest {
--bhsl-current-hue: 120deg;
--bhsl-current-saturation: 30%;
--bhsl-current-lightness: 45%;
font-weight: 200;
}

View file

@ -1,45 +0,0 @@
.taskPriorityHighest {
border: 4px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.45);
padding: 4px;
}
.taskPriorityHigh {
border: 3px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.25);
padding: 5px;
}
.taskPriorityNormal {
border: 2px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.15);
padding: 6px;
}
.taskPriorityLow {
border: 1px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.15);
padding: 7px;
}
.taskPriorityLowest {
border: 0;
padding: 8px;
}
.taskPriorityHighestColumnHeader {
text-decoration: underline 4px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 1.0);
}
.taskPriorityHighColumnHeader {
text-decoration: underline 3px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.75);
}
.taskPriorityNormalColumnHeader {
text-decoration: underline 2px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.50);
}
.taskPriorityLowColumnHeader {
text-decoration: underline 1px solid hsl(var(--bhsl-current-hue) var(--bhsl-current-saturation) var(--bhsl-current-lightness) / 0.25);
}
.taskPriorityLowestColumnHeader {
text-decoration: none;
}

View file

@ -1,44 +0,0 @@
@keyframes inProgress {
0%, 100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
.taskStatusUnfinished span {
}
.taskStatusInProgress span {
animation-name: inProgress;
animation-delay: 0s;
animation-direction: normal;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
.taskStatusComplete span {
opacity: 0.4;
text-decoration: 2px currentColor solid line-through;
}
.taskStatusUnfinishedColumnHeader {
}
.taskStatusInProgressColumnHeader {
animation-name: inProgress;
animation-delay: 0s;
animation-direction: normal;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
}
.taskStatusCompleteColumnHeader {
opacity: 0.4;
text-decoration: 2px currentColor solid line-through;
}

View file

@ -1,17 +1,16 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {ColumningMode} from "@/app/[lang]/board/[board]/(page)/(view)/(columning)"
import {GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)"
import {SortingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)"
import {SplashWithIcon} from "@/app/[lang]/board/[board]/(page)/(view)/SplashWithIcon"
import {BoardViewer} from "@/app/[lang]/board/[board]/(page)/(view)/BoardViewer"
import {faLink, faLinkSlash, faGear, faAsterisk} from "@fortawesome/free-solid-svg-icons"
import {SplashWithIcon} from "@/app/[lang]/board/[board]/(page)/(view)/SplashWithIcon"
import {faAsterisk, faGear, faLink, faLinkSlash} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import {TFunction} from "i18next"
import {Dispatch, SetStateAction} from "react"
export function BoardMain({lang, className, columning, sorting, grouping, setEditorInput}: {lang: string, className?: string, columning: ColumningMode, grouping: GroupingMode, sorting: SortingMode[], setEditorInput: Dispatch<SetStateAction<string>>}) {
const {t} = useClientTranslation(lang, "board")
export function BoardMain({t, className, columning, sorting, grouping, setEditorInput}: {t: TFunction, className?: string, columning: ColumningMode, grouping: GroupingMode, sorting: SortingMode[], setEditorInput: Dispatch<SetStateAction<string>>}) {
const {webSocketState, webSocketBackoffMs, boardState} = useBoardConsumer()
switch(webSocketState) {
@ -28,7 +27,7 @@ export function BoardMain({lang, className, columning, sorting, grouping, setEdi
className={className}
/>
case WebSocket.OPEN:
if(Object.keys(boardState.tasksById).length === 0) {
if(Object.keys(boardState.tasks).length === 0) {
return <SplashWithIcon
icon={<FontAwesomeIcon size={"4x"} icon={faAsterisk}/>}
text={t("boardEmpty")}
@ -37,7 +36,7 @@ export function BoardMain({lang, className, columning, sorting, grouping, setEdi
}
else {
return <BoardViewer
lang={lang}
t={t}
columning={columning}
sorting={sorting}
grouping={grouping}

View file

@ -1,19 +1,20 @@
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {ColumningMode} from "@/app/[lang]/board/[board]/(page)/(view)/(columning)"
import {GROUPING_MODE_TO_TITLE_COMPONENT, GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/TaskGroup"
import {SortingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskGroup"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
import {TaskViewer} from "@/app/[lang]/board/[board]/(page)/(view)/TaskViewer"
import {TaskViewer} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskViewer"
import {useBoardTasksArranger} from "@/app/[lang]/board/[board]/(page)/(view)/useBoardTasksArranger"
import cn from "classnames"
import {TFunction} from "i18next"
import {Dispatch, SetStateAction} from "react"
import style from "./BoardViewer.module.css"
export function BoardViewer({className, lang, columning, grouping, sorting, setEditorInput}: {className?: string, lang: string, columning: ColumningMode, grouping: GroupingMode, sorting: SortingMode[], setEditorInput: Dispatch<SetStateAction<string>>}) {
const {boardState: {tasksById}} = useBoardConsumer()
const {taskGroups} = useBoardTasksArranger(tasksById, grouping, sorting);
export function BoardViewer({className, t, columning, grouping, sorting, setEditorInput}: {className?: string, t: TFunction, columning: ColumningMode, grouping: GroupingMode, sorting: SortingMode[], setEditorInput: Dispatch<SetStateAction<string>>}) {
const {boardState: {tasks}} = useBoardConsumer()
const {taskGroups} = useBoardTasksArranger(tasks, grouping, sorting);
return (
<main className={cn(className, {
@ -21,21 +22,21 @@ export function BoardViewer({className, lang, columning, grouping, sorting, setE
[style.boardMainTaskGroupsMultiColumn]: columning === ColumningMode.MultiColumn,
[style.boardMainTaskGroupsSingleColumn]: columning === ColumningMode.SingleColumn,
})}>
{taskGroups.map((tg) => <BoardViewerColumn lang={lang} taskGroup={tg} key={tg.k} grouping={grouping} setEditorInput={setEditorInput}/>)}
{taskGroups.map((tg) => <BoardViewerColumn t={t} taskGroup={tg} key={tg.k} grouping={grouping} setEditorInput={setEditorInput}/>)}
</main>
)
}
export function BoardViewerColumn({lang, grouping, taskGroup, setEditorInput}: {lang: string, grouping: GroupingMode, taskGroup: TaskGroup<string | number>, setEditorInput: Dispatch<SetStateAction<string>>}) {
export function BoardViewerColumn({t, grouping, taskGroup, setEditorInput}: {t: TFunction, grouping: GroupingMode, taskGroup: TaskGroup<string | number>, setEditorInput: Dispatch<SetStateAction<string>>}) {
const ColumnTitle = GROUPING_MODE_TO_TITLE_COMPONENT[grouping]
return (
<div className={style.boardColumn}>
<h3>
<ColumnTitle lang={lang} k={taskGroup.k}/>
<ColumnTitle t={t} k={taskGroup.k}/>
</h3>
<div className={style.boardColumnContents}>
{taskGroup.tasks.map((task: TaskWithId) => <TaskViewer lang={lang} task={task} key={task[0]} setEditorInput={setEditorInput}/>)}
{taskGroup.tasks.map((task: TaskWithId) => <TaskViewer t={t} taskWithId={task} key={task[0]} setEditorInput={setEditorInput}/>)}
</div>
</div>
)

View file

@ -1,28 +0,0 @@
.taskViewer {
flex-direction: row;
align-items: center;
min-width: 240px;
min-height: 48px;
cursor: pointer;
}
.taskIcon {
display: flex;
flex-shrink: 0;
width: 28px;
height: 100%;
flex-direction: column;
justify-content: center;
cursor: pointer;
}
.taskViewerBack {
justify-content: end;
}
.taskViewerDebug {
font-size: xx-small;
}

View file

@ -1,112 +0,0 @@
import {useClientTranslation} from "@/app/(i18n)/client"
import {DeleteTaskBoardRequest, ModifyTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
import {TASK_ICON_TO_FONTAWESOME_REGULAR, TASK_ICON_TO_FONTAWESOME_SOLID, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
import {taskToString} from "@/app/[lang]/board/[board]/(page)/(edit)/taskToString"
import {taskClassNames} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/taskClassNames"
import style from "@/app/[lang]/board/[board]/(page)/(view)/TaskViewer.module.css"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
import {faTrashArrowUp} from "@fortawesome/free-solid-svg-icons"
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
import cn from "classnames"
import {Dispatch, SyntheticEvent, SetStateAction, useCallback, useState} from "react"
export function TaskViewer({lang, task, setEditorInput}: {lang: string, task: TaskWithId, setEditorInput: Dispatch<SetStateAction<string>>}) {
const [isFlipped, setFlipped] = useState<boolean>(false)
const toggleFlipped = useCallback((e: SyntheticEvent<HTMLElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
setFlipped(prev => !prev)
}, [])
return (
<div
className={cn({
"panel": true,
[style.taskViewer]: true,
[style.taskViewerFront]: !isFlipped,
[style.taskViewerBack]: isFlipped,
}, taskClassNames(task[1]))}
onClick={toggleFlipped}
onKeyDownCapture={toggleFlipped}
>
{isFlipped ? <TaskViewerBack lang={lang} task={task} setEditorInput={setEditorInput}/> : <TaskViewerFront lang={lang} task={task}/>}
</div>
)
}
function TaskViewerFront({task}: {lang: string, task: TaskWithId}) {
const {sendRequest} = useBoardConsumer()
const toggleStatus = useCallback((e: SyntheticEvent<HTMLDivElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
let request: ModifyTaskBoardRequest
switch(task[1].status) {
case TaskStatus.Unfinished:
request = {"Task": [task[0], {...task[1], status: TaskStatus.InProgress}]}
break
case TaskStatus.InProgress:
request = {"Task": [task[0], {...task[1], status: TaskStatus.Complete}]}
break
case TaskStatus.Complete:
request = {"Task": [task[0], {...task[1], status: TaskStatus.Unfinished}]}
break
}
sendRequest(request)
}, [task, sendRequest])
return <>
<div className={style.taskIcon} onClick={toggleStatus} onKeyDown={toggleStatus} tabIndex={0}>
<FontAwesomeIcon
size={"lg"}
icon={task[1].status === TaskStatus.Complete ? TASK_ICON_TO_FONTAWESOME_SOLID[task[1].icon] : TASK_ICON_TO_FONTAWESOME_REGULAR[task[1].icon]}
beatFade={task[1].status === TaskStatus.InProgress}
/>
</div>
<span className={style.taskDescription} tabIndex={0}>
{task[1].text}
</span>
</>
}
function TaskViewerBack({lang, task, setEditorInput}: {lang: string, task: TaskWithId, setEditorInput: Dispatch<SetStateAction<string>>}) {
const {t} = useClientTranslation(lang, "board")
const {sendRequest} = useBoardConsumer()
const recreateTask = useCallback((e: SyntheticEvent<HTMLButtonElement>) => {
if("key" in e && typeof e["key"] === "string") {
if(!["Enter", " "].includes(e.key)) {
return;
}
}
e.preventDefault()
e.stopPropagation()
setEditorInput(taskToString(task[1]))
const request: DeleteTaskBoardRequest = {"Task": [task[0], null]};
sendRequest(request)
}, [task, setEditorInput, sendRequest])
return <>
<div className={style.taskViewerDebug}>
{task[0]}
</div>
<div className={style.taskViewerButtons}>
<button title={t("taskButtonRecreate")} onClick={recreateTask} onKeyDown={recreateTask} tabIndex={0}>
<FontAwesomeIcon size={"sm"} icon={faTrashArrowUp}/>
</button>
</div>
</>
}

View file

@ -1,19 +1,20 @@
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(task)/TaskWithId"
import {GROUPING_MODE_TO_GROUP_SORTER_FUNCTION, GROUPING_MODE_TO_TASK_GROUPER_FUNCTION, GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)/TaskGroup"
import {SortingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)"
import {getTaskSorter} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)/sortingModeToSortingFunction"
import {TaskGroup} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskGroup"
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
import {useMemo} from "react"
export function useBoardTasksArranger(tasksById: {[id: string]: Task}, grouping: GroupingMode, sorting: SortingMode[]) {
export function useBoardTasksArranger(tasks: {[id: string]: Task}, grouping: GroupingMode, sorting: SortingMode[]) {
const taskGroups = useMemo(() => {
const taskGrouperFunction = GROUPING_MODE_TO_TASK_GROUPER_FUNCTION[grouping]
const keyedTaskGroups: {[t: string | number]: TaskWithId[]} = {}
Object.entries(tasksById).forEach((t: TaskWithId) => {
Object.entries(tasks).forEach((t: TaskWithId) => {
const group = taskGrouperFunction(t)
if(group === null) return;
const array = keyedTaskGroups[group] ?? []
keyedTaskGroups[group] = [...array, t]
})
@ -29,7 +30,7 @@ export function useBoardTasksArranger(tasksById: {[id: string]: Task}, grouping:
taskGroups.sort(groupSorterFunction)
return taskGroups as TaskGroup<string | number>[]
}, [tasksById, grouping, sorting])
}, [tasks, grouping, sorting])
return {
taskGroups,

Some files were not shown because too many files have changed in this diff Show more