mirror of
https://github.com/Steffo99/todocolors.git
synced 2024-11-25 01:34:18 +00:00
Refactor the whole frontend into something with probably more sense
This commit is contained in:
parent
948abb30d0
commit
d7f704ea49
115 changed files with 1870 additions and 1536 deletions
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
7
.idea/inspectionProfiles/profiles_settings.xml
Normal file
|
@ -0,0 +1,7 @@
|
|||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="PROJECT_PROFILE" value="Default" />
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
|
@ -1,3 +1,31 @@
|
|||
{
|
||||
"headerToggleEditingTitle": "Toggle editing board settings"
|
||||
"startEditingButtonTitle": "Edit global board settings",
|
||||
"stopEditingButtonTitle": "Save changes to the global board settings and stop editing",
|
||||
"addStarredButtonTitle": "Star this board",
|
||||
"removeStarredButtonTitle": "Unstar this board",
|
||||
"navigateHomeButtonTitle": "Return to the home page",
|
||||
"cycleColumningButtonTitle": "Change column arrangement",
|
||||
"cycleGroupingButtonTitle": "Change column grouping",
|
||||
"cycleSortingButtonTitle": "Change column sorting",
|
||||
"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",
|
||||
"taskButtonDelete": "Delete this task",
|
||||
"taskButtonRecreate": "Delete and go back to editing this task",
|
||||
"editorPlaceholder": "What do you want to do?"
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ export function useClientTranslation(lang: string, ns: string) {
|
|||
)
|
||||
|
||||
return {
|
||||
t: instance?.getFixedT(lang, Array.isArray(ns) ? ns[0] : ns) ?? ((...args) => `${args}`),
|
||||
t: instance?.getFixedT(lang, Array.isArray(ns) ? ns[0] : ns) ?? ((...args) => `${args}`) as (...args: any) => string, // FIXME: Typing for this function is incorrect.
|
||||
i18n: instance,
|
||||
}
|
||||
}
|
||||
|
|
3
todoblue/src/app/(utils)/(kebab)/index.ts
Normal file
3
todoblue/src/app/(utils)/(kebab)/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export {useKebabifier} from "./useKebabifier"
|
||||
export {useLowerKebabifier} from "./useLowerKebabifier"
|
||||
export {useUpperKebabifier} from "./useUpperKebabifier"
|
4
todoblue/src/app/(utils)/(kebab)/kebabifier.ts
Normal file
4
todoblue/src/app/(utils)/(kebab)/kebabifier.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* **Regex** identifying the characters to be replaced with dashes in {@link useKebabifier}, {@link useLowerKebabifier}, and {@link useUpperKebabifier}.
|
||||
*/
|
||||
export const KEBABIFIER = /[^a-zA-Z0-9-]/g
|
19
todoblue/src/app/(utils)/(kebab)/useKebabifier.ts
Normal file
19
todoblue/src/app/(utils)/(kebab)/useKebabifier.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {KEBABIFIER} from "@/app/(utils)/(kebab)/kebabifier"
|
||||
import {ReturnTypeOfUseState} from "@/app/(utils)/ReturnTypeOfUseState"
|
||||
import {useCallback, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** which alters a hook with the same interface as {@link useState}, but replaces non-alphanumeric characters with dashes.
|
||||
* @param stateHook The hook to alter.
|
||||
*/
|
||||
export function useKebabifier(stateHook: ReturnTypeOfUseState<string | undefined>): [string | undefined, (v: string) => void] {
|
||||
const [state, setInnerState] = stateHook
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
19
todoblue/src/app/(utils)/(kebab)/useLowerKebabifier.ts
Normal file
19
todoblue/src/app/(utils)/(kebab)/useLowerKebabifier.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {KEBABIFIER} from "@/app/(utils)/(kebab)/kebabifier"
|
||||
import {ReturnTypeOfUseState} from "@/app/(utils)/ReturnTypeOfUseState"
|
||||
import {Dispatch, useCallback, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** which alters a hook with the same interface as {@link useState}, but replaces non-alphanumeric characters with dashes and converts to lowercase the whole string.
|
||||
* @param stateHook The hook to alter.
|
||||
*/
|
||||
export function useLowerKebabifier(stateHook: ReturnTypeOfUseState<string | undefined>): [string | undefined, (v: string) => void] {
|
||||
const [state, setInnerState] = stateHook
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.toLowerCase().replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
19
todoblue/src/app/(utils)/(kebab)/useUpperKebabifier.ts
Normal file
19
todoblue/src/app/(utils)/(kebab)/useUpperKebabifier.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import {KEBABIFIER} from "@/app/(utils)/(kebab)/kebabifier"
|
||||
import {ReturnTypeOfUseState} from "@/app/(utils)/ReturnTypeOfUseState"
|
||||
import {useCallback, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** which alters a hook with the same interface as {@link useState}, but replaces non-alphanumeric characters with dashes and converts to uppercase the whole string.
|
||||
* @param stateHook The hook to alter.
|
||||
*/
|
||||
export function useUpperKebabifier(stateHook: ReturnTypeOfUseState<string | undefined>): [string | undefined, (v: string) => void] {
|
||||
const [state, setInnerState] = stateHook
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.toUpperCase().replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
8
todoblue/src/app/(utils)/ReturnTypeOfUseState.ts
Normal file
8
todoblue/src/app/(utils)/ReturnTypeOfUseState.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import {Dispatch, SetStateAction} from "react"
|
||||
|
||||
|
||||
export type ReturnTypeOfUseState<S> = [
|
||||
S,
|
||||
Dispatch<SetStateAction<S>>,
|
||||
]
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
"use client";
|
||||
import {useCallback, useMemo, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** similar to {@link useState}, but which allows a value to be chosen from a cycle of items.
|
||||
*
|
||||
* @param items The items in the cycle.
|
||||
*/
|
||||
export function useCycleState(items: any[]) {
|
||||
const [index, setIndex] = useState<number>(0);
|
||||
|
||||
const value = useMemo(() => items[index], [index])
|
||||
|
||||
const move = useCallback((num: number) => {
|
||||
setIndex((prevIndex) => (prevIndex + num) % items.length);
|
||||
}, [items])
|
||||
|
||||
const next = useCallback(() => move(1), [move]);
|
||||
const previous = useCallback(() => move(-1), [move]);
|
||||
|
||||
return {index, value, move, next, previous}
|
||||
}
|
37
todoblue/src/app/(utils)/useCycler.ts
Normal file
37
todoblue/src/app/(utils)/useCycler.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
import {ReturnTypeOfUseState} from "@/app/(utils)/ReturnTypeOfUseState"
|
||||
import {useCallback, useMemo, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** which alters a hook with the same interface as {@link useState}, to allow a value to be chosen from a cycle of items.
|
||||
*
|
||||
* @param stateHook The hook to alter.
|
||||
* @param items The items in the cycle.
|
||||
*/
|
||||
export function useCycler<T>(stateHook: ReturnTypeOfUseState<number | undefined>, items: T[]) {
|
||||
const [index, setIndex] = stateHook;
|
||||
|
||||
const value = useMemo(() => {
|
||||
let actualIndex = index;
|
||||
if(actualIndex === undefined) {
|
||||
actualIndex = 0;
|
||||
}
|
||||
return items[actualIndex]
|
||||
}, [index])
|
||||
|
||||
const move = useCallback((num: number) => {
|
||||
setIndex((prevIndex) => {
|
||||
let actualIndex = prevIndex;
|
||||
if(actualIndex === undefined) {
|
||||
actualIndex = 0;
|
||||
}
|
||||
return (actualIndex + num) % items.length
|
||||
});
|
||||
}, [items])
|
||||
|
||||
const next = useCallback(() => move(1), [move]);
|
||||
const previous = useCallback(() => move(-1), [move]);
|
||||
|
||||
return {index, value, move, next, previous}
|
||||
}
|
|
@ -1,54 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {useCallback, useState} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Regex** identifying the characters to be replaced with dashes in {@link useAnyKebabState}, {@link useLowerKebabState}, and {@link useUpperKebabState}.
|
||||
*/
|
||||
const KEBABIFIER = /[^a-zA-Z0-9-]/g
|
||||
|
||||
/**
|
||||
* **Hook** similar to {@link useState}, but replace non-alphanumeric characters with dashes.
|
||||
* @param initial
|
||||
*/
|
||||
export function useAnyKebabState(initial: string): [string, (inputString: string) => void] {
|
||||
const [state, setInnerState] = useState<string>(initial);
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
||||
|
||||
/**
|
||||
* **Hook** similar to {@link useState}, but replaces non-alphanumeric characters with dashes and converts to lowercase the whole string.
|
||||
* @param initial
|
||||
*/
|
||||
export function useLowerKebabState(initial: string): [string, (inputString: string) => void] {
|
||||
const [state, setInnerState] = useState<string>(initial);
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.toLowerCase().replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
||||
|
||||
/**
|
||||
* **Hook** similar to {@link useState}, but replaces non-alphanumeric characters with dashes and converts to uppercase the whole string.
|
||||
* @param initial
|
||||
*/
|
||||
export function useUpperKebabState(initial: string): [string, (inputString: string) => void] {
|
||||
const [state, setInnerState] = useState<string>(initial);
|
||||
|
||||
const setState = useCallback((inputString: string) => {
|
||||
const kebabifiedString = inputString.toUpperCase().replaceAll(KEBABIFIER, "-");
|
||||
setInnerState(kebabifiedString);
|
||||
}, [])
|
||||
|
||||
return [state, setState]
|
||||
}
|
|
@ -13,9 +13,10 @@ export function StarredProvider({children}: {children: ReactNode}) {
|
|||
if(!prev) {
|
||||
return [value]
|
||||
}
|
||||
else {
|
||||
return [...prev, value]
|
||||
if(prev.indexOf(value) >= 0) {
|
||||
return prev
|
||||
}
|
||||
return [...prev, value]
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
@ -39,7 +40,7 @@ export function StarredProvider({children}: {children: ReactNode}) {
|
|||
}
|
||||
const result = [...prev]
|
||||
const index = result.indexOf(value)
|
||||
if(!index) {
|
||||
if(index <= -1) {
|
||||
result.push(value)
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useLowerKebabState} from "@/app/(utils)/useKebabState"
|
||||
import {useLowerKebabifier} from "@/app/(utils)/(kebab)"
|
||||
import {faGlobe} from "@fortawesome/free-solid-svg-icons"
|
||||
import cn from "classnames"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {default as React, SyntheticEvent, useCallback} from "react"
|
||||
import {default as React, SyntheticEvent, useCallback, useState} from "react"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
|
||||
|
||||
export function CreatePublicBoardPanel({lang}: {lang: string}) {
|
||||
const {t} = useClientTranslation(lang, "root")
|
||||
const [code, setCode] = useLowerKebabState("")
|
||||
const [code, setCode] = useLowerKebabifier(useState<string | undefined>(undefined))
|
||||
const router = useRouter();
|
||||
|
||||
const codeIsValid = code.length >= 1
|
||||
const codeIsValid = code && code.length >= 1
|
||||
|
||||
const createBoardValidated = useCallback((e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
@ -48,7 +48,7 @@ export function CreatePublicBoardPanel({lang}: {lang: string}) {
|
|||
<input
|
||||
type={"text"}
|
||||
placeholder={t("createPublicBoardCodePlaceholder")}
|
||||
value={code}
|
||||
value={code ?? ""}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
/>
|
||||
<span/>
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useLowerKebabState} from "@/app/(utils)/useKebabState"
|
||||
import {useLowerKebabifier} from "@/app/(utils)/(kebab)"
|
||||
import {faKey} from "@fortawesome/free-solid-svg-icons"
|
||||
import cn from "classnames"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {default as React, SyntheticEvent, useCallback} from "react"
|
||||
import {default as React, SyntheticEvent, useCallback, useState} from "react"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
|
||||
|
||||
export function KnownBoardsPanel({lang}: {lang: string}) {
|
||||
const {t} = useClientTranslation(lang, "root")
|
||||
const [code, setCode] = useLowerKebabState("")
|
||||
const [code, setCode] = useLowerKebabifier(useState<string | undefined>(undefined))
|
||||
const router = useRouter();
|
||||
|
||||
const codeIsValid = code.length >= 1
|
||||
const codeIsValid = code && code.length >= 1
|
||||
|
||||
const moveToBoardValidated = useCallback((e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
|
@ -48,7 +48,7 @@ export function KnownBoardsPanel({lang}: {lang: string}) {
|
|||
<input
|
||||
type={"text"}
|
||||
placeholder={t("existingKnownBoardsCodePlaceholder")}
|
||||
value={code}
|
||||
value={code ?? ""}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
/>
|
||||
<span/>
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import {TaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/TaskBoardRequest"
|
||||
import {TitleBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/TitleBoardRequest"
|
||||
|
||||
|
||||
/**
|
||||
* **Object** requesting the server to perform a certain action on the board.
|
||||
*/
|
||||
export type BoardRequest = TitleBoardRequest | TaskBoardRequest;
|
|
@ -0,0 +1,11 @@
|
|||
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
/**
|
||||
* **Object** requesting the creation of a new {@link Task} on the board.
|
||||
*/
|
||||
export type CreateTaskBoardRequest = {
|
||||
"Task": [
|
||||
null,
|
||||
Task
|
||||
]
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import {TaskId} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
/**
|
||||
* **Object** requesting the deletion of the {@link Task} with the given {@link TaskId} from the board.
|
||||
*/
|
||||
export type DeleteTaskBoardRequest = {
|
||||
"Task": [
|
||||
TaskId,
|
||||
null
|
||||
]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
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}.
|
||||
*/
|
||||
export type ModifyTaskBoardRequest = {
|
||||
"Task": [
|
||||
TaskId,
|
||||
Task,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import {CreateTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/CreateTaskBoardRequest"
|
||||
import {DeleteTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/DeleteTaskBoardRequest"
|
||||
import {ModifyTaskBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)/ModifyTaskBoardRequest"
|
||||
|
||||
|
||||
/**
|
||||
* **Object** requesting an alteration to a {@link Task} related to the board.
|
||||
*/
|
||||
export type TaskBoardRequest = CreateTaskBoardRequest | ModifyTaskBoardRequest | DeleteTaskBoardRequest
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* **Object** requesting a change to the title of the board.
|
||||
*/
|
||||
export type TitleBoardRequest = {
|
||||
"Title": string,
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export type {ModifyTaskBoardRequest} from "./ModifyTaskBoardRequest"
|
||||
export type {DeleteTaskBoardRequest} from "./DeleteTaskBoardRequest"
|
||||
export type {TaskBoardRequest} from "./TaskBoardRequest"
|
||||
export type {TitleBoardRequest} from "./TitleBoardRequest"
|
||||
export type {BoardRequest} from "./BoardRequest"
|
|
@ -0,0 +1,4 @@
|
|||
import {TaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)/TaskBoardSignal"
|
||||
|
||||
|
||||
export type BoardSignal = TaskBoardSignal;
|
|
@ -0,0 +1,12 @@
|
|||
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 = {
|
||||
"Task": [
|
||||
TaskId,
|
||||
null
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
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;
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* **Object** signaling a change to the title of the board.
|
||||
*/
|
||||
export type TitleBoardSignal = {
|
||||
"Title": string,
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
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 = {
|
||||
"Task": [
|
||||
TaskId,
|
||||
Task,
|
||||
]
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
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"
|
|
@ -0,0 +1,10 @@
|
|||
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
/**
|
||||
* **Object** containing information about the state of the board in a given moment.
|
||||
*/
|
||||
export type BoardState = {
|
||||
title: string,
|
||||
tasksById: {[key: string]: Task},
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import {BoardSignal, DeleteTaskBoardSignal, TaskBoardSignal, TitleBoardSignal, UpdateTaskBoardSignal} from "@/app/[lang]/board/[board]/(api)/(signal)"
|
||||
import {BoardState} from "@/app/[lang]/board/[board]/(api)/(state)/BoardState"
|
||||
import {DEFAULT_BOARD_STATE} from "@/app/[lang]/board/[board]/(api)/(state)/defaultBoardState"
|
||||
|
||||
/**
|
||||
* **Reducer** updating a {@link BoardState} with a single new {@link BoardSignal}.
|
||||
*
|
||||
* If `null` is passed as signal, 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.
|
||||
*/
|
||||
export function boardReducer(state: BoardState, action: BoardSignal | null) {
|
||||
if(action === null) {
|
||||
console.debug("[boardReducer] Initializing state...");
|
||||
return DEFAULT_BOARD_STATE
|
||||
}
|
||||
else if("Title" in action) {
|
||||
const titleAction = action as TitleBoardSignal;
|
||||
const title = titleAction["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}
|
||||
if(task === null) {
|
||||
const deleteAction = taskAction as DeleteTaskBoardSignal;
|
||||
const id = deleteAction["Task"][0]
|
||||
console.debug("[boardReducer] Deleting task:", id)
|
||||
delete tasksById[id]
|
||||
}
|
||||
else {
|
||||
const updateAction = taskAction as UpdateTaskBoardSignal;
|
||||
const id = updateAction["Task"][0] as string
|
||||
console.debug("[boardReducer] Putting task:", id)
|
||||
tasksById[id] = task
|
||||
}
|
||||
return {...state, tasksById}
|
||||
}
|
||||
else {
|
||||
console.warn("[boardReducer] Received unknown signal, ignoring:", action)
|
||||
return state
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
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: {}}
|
|
@ -0,0 +1 @@
|
|||
export {useBoardState} from "./useBoardState"
|
|
@ -0,0 +1,12 @@
|
|||
"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 {Reducer, useReducer} from "react"
|
||||
|
||||
export function useBoardState() {
|
||||
const [boardState, processBoardSignal] = useReducer<Reducer<BoardState, BoardSignal | null>>(boardReducer, {title: "", tasksById: {}})
|
||||
|
||||
return {boardState, processBoardSignal}
|
||||
}
|
13
todoblue/src/app/[lang]/board/[board]/(api)/(task)/Task.ts
Normal file
13
todoblue/src/app/[lang]/board/[board]/(api)/(task)/Task.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
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 = {
|
||||
text: string,
|
||||
icon: TaskIcon,
|
||||
importance: TaskImportance,
|
||||
priority: TaskPriority,
|
||||
status: TaskStatus,
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* **Enum** of icons that a {@link Task} may have.
|
||||
*/
|
||||
export enum TaskIcon {
|
||||
Bookmark = "Bookmark",
|
||||
Circle = "Circle",
|
||||
Square = "Square",
|
||||
Heart = "Heart",
|
||||
Star = "Star",
|
||||
Sun = "Sun",
|
||||
Moon = "Moon",
|
||||
Eye = "Eye",
|
||||
Hand = "Hand",
|
||||
Handshake = "Handshake",
|
||||
FaceSmile = "FaceSmile",
|
||||
User = "User",
|
||||
Comment = "Comment",
|
||||
Envelope = "Envelope",
|
||||
File = "File",
|
||||
PaperPlane = "PaperPlane",
|
||||
Building = "Building",
|
||||
Flag = "Flag",
|
||||
Bell = "Bell",
|
||||
Clock = "Clock",
|
||||
Image = "Image",
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
/**
|
||||
* Unique **string** representing a task on a board.
|
||||
*/
|
||||
export type TaskId = string;
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* **Enum** of importance levels a {@link Task} may have.
|
||||
*/
|
||||
export enum TaskImportance {
|
||||
Highest = "Highest",
|
||||
High = "High",
|
||||
Normal = "Normal",
|
||||
Low = "Low",
|
||||
Lowest = "Lowest",
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* **Enum** of priority levels a {@link Task} may have.
|
||||
*/
|
||||
export enum TaskPriority {
|
||||
Highest = "Highest",
|
||||
High = "High",
|
||||
Normal = "Normal",
|
||||
Low = "Low",
|
||||
Lowest = "Lowest",
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* **Enum** of possible stati a {@link Task} can be in.
|
||||
*/
|
||||
export enum TaskStatus {
|
||||
Unfinished = "Unfinished",
|
||||
InProgress = "InProgress",
|
||||
Complete = "Complete",
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
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"
|
||||
export type {TaskId} from "./TaskId"
|
|
@ -0,0 +1,52 @@
|
|||
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"
|
||||
|
||||
|
||||
export const TASK_ICON_TO_FONTAWESOME_SOLID = {
|
||||
[TaskIcon.User]: faUserSolid,
|
||||
[TaskIcon.Image]: faImageSolid,
|
||||
[TaskIcon.Envelope]: faEnvelopeSolid,
|
||||
[TaskIcon.Star]: faStarSolid,
|
||||
[TaskIcon.Heart]: faHeartSolid,
|
||||
[TaskIcon.Comment]: faCommentSolid,
|
||||
[TaskIcon.FaceSmile]: faFaceSmileSolid,
|
||||
[TaskIcon.File]: faFileSolid,
|
||||
[TaskIcon.Bell]: faBellSolid,
|
||||
[TaskIcon.Bookmark]: faBookmarkSolid,
|
||||
[TaskIcon.Eye]: faEyeSolid,
|
||||
[TaskIcon.Hand]: faHandSolid,
|
||||
[TaskIcon.PaperPlane]: faPaperPlaneSolid,
|
||||
[TaskIcon.Handshake]: faHandshakeSolid,
|
||||
[TaskIcon.Sun]: faSunSolid,
|
||||
[TaskIcon.Clock]: faClockSolid,
|
||||
[TaskIcon.Circle]: faCircleSolid,
|
||||
[TaskIcon.Square]: faSquareSolid,
|
||||
[TaskIcon.Building]: faBuildingSolid,
|
||||
[TaskIcon.Flag]: faFlagSolid,
|
||||
[TaskIcon.Moon]: faMoonSolid,
|
||||
}
|
||||
|
||||
export const TASK_ICON_TO_FONTAWESOME_REGULAR = {
|
||||
[TaskIcon.User]: faUserRegular,
|
||||
[TaskIcon.Image]: faImageRegular,
|
||||
[TaskIcon.Envelope]: faEnvelopeRegular,
|
||||
[TaskIcon.Star]: faStarRegular,
|
||||
[TaskIcon.Heart]: faHeartRegular,
|
||||
[TaskIcon.Comment]: faCommentRegular,
|
||||
[TaskIcon.FaceSmile]: faFaceSmileRegular,
|
||||
[TaskIcon.File]: faFileRegular,
|
||||
[TaskIcon.Bell]: faBellRegular,
|
||||
[TaskIcon.Bookmark]: faBookmarkRegular,
|
||||
[TaskIcon.Eye]: faEyeRegular,
|
||||
[TaskIcon.Hand]: faHandRegular,
|
||||
[TaskIcon.PaperPlane]: faPaperPlaneRegular,
|
||||
[TaskIcon.Handshake]: faHandshakeRegular,
|
||||
[TaskIcon.Sun]: faSunRegular,
|
||||
[TaskIcon.Clock]: faClockRegular,
|
||||
[TaskIcon.Circle]: faCircleRegular,
|
||||
[TaskIcon.Square]: faSquareRegular,
|
||||
[TaskIcon.Building]: faBuildingRegular,
|
||||
[TaskIcon.Flag]: faFlagRegular,
|
||||
[TaskIcon.Moon]: faMoonRegular,
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
'use client';
|
||||
|
||||
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"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** connecting to the WebSocket of the specified board (with {@link useBoardWsURL} and {@link useWs}), and then tracking its real-time state (with {@link useBoardState}).
|
||||
*
|
||||
* @param boardName The name of the board to track.
|
||||
*/
|
||||
export function useBoardWs(boardName: string) {
|
||||
const wsFullURL = useBoardWsURL(boardName)
|
||||
|
||||
const {boardState, processBoardSignal} = useBoardState();
|
||||
|
||||
const {webSocket, webSocketState, webSocketBackoffMs} = useWs(wsFullURL, {
|
||||
|
||||
onopen: useCallback(({}) => {
|
||||
console.debug("[useBoardWs] Opened connection, resetting BoardState to default:", boardName);
|
||||
processBoardSignal(null);
|
||||
}, [processBoardSignal]),
|
||||
|
||||
onmessage: useCallback(({event}: WebSocketHandlerParams<MessageEvent>) => {
|
||||
const action: BoardSignal = JSON.parse(event.data);
|
||||
console.debug("[useBoardWs] Received signal:", action);
|
||||
processBoardSignal(action)
|
||||
}, [processBoardSignal]),
|
||||
|
||||
onerror: useCallback(({event, closeWebSocket}: WebSocketHandlerParams<Event>) => {
|
||||
console.error("[useBoardWs] Encountered a WebSocket error, closing current connection:", event);
|
||||
closeWebSocket()
|
||||
}, []),
|
||||
|
||||
onclose: useCallback(({event}: WebSocketHandlerParams<Event>) => {
|
||||
console.debug("[useBoardWs] Closed connection:", event);
|
||||
}, [])
|
||||
|
||||
});
|
||||
|
||||
const sendRequest = useCallback((data: BoardRequest) => {
|
||||
if(!webSocket || webSocketState !== WebSocket.OPEN) {
|
||||
console.warn("[useBoardWs] WebSocket is not yet ready, cannot send:", data);
|
||||
return;
|
||||
}
|
||||
console.debug("[useBoardWs] Sending request:", data);
|
||||
webSocket.send(JSON.stringify(data));
|
||||
}, [webSocket, webSocketState])
|
||||
|
||||
const isReady = useMemo(() => webSocket !== null && webSocketState === WebSocket.OPEN, [webSocket, webSocketState])
|
||||
|
||||
return {boardName, boardState, sendRequest, webSocketState, webSocketBackoffMs, isReady}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import {useWsBaseURL} from "@/app/(api)/useWsBaseURL"
|
||||
import {useMemo} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Hook** using {@link useWsBaseURL} to return the qualified URL to the WebSocket of the board with the given name.
|
||||
*
|
||||
* @param name The name of the board to use.
|
||||
*/
|
||||
export function useBoardWsURL(name: string) {
|
||||
const wsBaseURL = useWsBaseURL()
|
||||
// noinspection UnnecessaryLocalVariableJS
|
||||
const wsFullURL = useMemo(() => wsBaseURL ? `${wsBaseURL}/board/${name}/ws` : undefined, [wsBaseURL, name])
|
||||
|
||||
return wsFullURL
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
"use client";
|
||||
import {useBoardWs} from "@/app/[lang]/board/[board]/(api)/(ws)/useBoardWs"
|
||||
import {createContext} from "react"
|
||||
|
||||
|
||||
export const BoardContext = createContext<ReturnType<typeof useBoardWs> | null>(null);
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
import {useBoardWs} from "@/app/[lang]/board/[board]/(api)/(ws)/useBoardWs"
|
||||
import {BoardContext} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)/BoardContext"
|
||||
import {ReactNode} from "react"
|
||||
|
||||
|
||||
export function BoardProvider({children, name}: {children: ReactNode, name: string}) {
|
||||
const value = useBoardWs(name)
|
||||
|
||||
return (
|
||||
<BoardContext.Provider value={value}>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export {BoardProvider} from "./BoardProvider"
|
||||
export {useBoardConsumer} from "./useBoardConsumer"
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
import {BoardContext} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)/BoardContext"
|
||||
import {useContext} from "react"
|
||||
|
||||
|
||||
export function useBoardConsumer() {
|
||||
const context = useContext(BoardContext)
|
||||
|
||||
if(context === null) {
|
||||
throw Error("useBoardConsumer used outside a BoardContext.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
.taskEditorContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.taskEditor {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
border-style: dashed;
|
||||
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.taskEditorIcon {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.taskEditorInput {
|
||||
flex-grow: 1;
|
||||
border-bottom: 0;
|
||||
height: 100%;
|
||||
border-radius: var(--b-border-radius);
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
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, 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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import {TaskIcon} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
export const ICON_GLYPH = "#"
|
||||
|
||||
export const ICON_GLYPH_RE = /#([A-Za-z]+)\s?/
|
||||
|
||||
export const ICON_DEFAULT = TaskIcon.Circle
|
|
@ -0,0 +1,8 @@
|
|||
import {TaskImportance} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
export const IMPORTANCE_GLYPH = "!"
|
||||
|
||||
export const IMPORTANCE_GLYPH_RE = /!([1-5])\s?/
|
||||
|
||||
export const IMPORTANCE_DEFAULT = TaskImportance.Normal
|
|
@ -0,0 +1,8 @@
|
|||
import {TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
export const PRIORITY_GLYPH = "/"
|
||||
|
||||
export const PRIORITY_GLYPH_RE = /\/([1-5])\s?/
|
||||
|
||||
export const PRIORITY_DEFAULT = TaskPriority.Normal
|
|
@ -0,0 +1,69 @@
|
|||
import {Task, TaskIcon, TaskImportance, TaskPriority, TaskStatus} 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,
|
||||
"3": TaskImportance.Normal,
|
||||
"4": TaskImportance.Low,
|
||||
"5": TaskImportance.Lowest,
|
||||
}
|
||||
|
||||
const VALUE_TO_TASK_PRIORITY = {
|
||||
"1": TaskPriority.Highest,
|
||||
"2": TaskPriority.High,
|
||||
"3": TaskPriority.Normal,
|
||||
"4": TaskPriority.Low,
|
||||
"5": TaskPriority.Lowest,
|
||||
}
|
||||
|
||||
const VALUE_TO_TASK_ICON = {
|
||||
"bookmark": TaskIcon.Bookmark,
|
||||
"circle": TaskIcon.Circle,
|
||||
"square": TaskIcon.Square,
|
||||
"heart": TaskIcon.Heart,
|
||||
"star": TaskIcon.Star,
|
||||
"sun": TaskIcon.Sun,
|
||||
"moon": TaskIcon.Moon,
|
||||
"eye": TaskIcon.Eye,
|
||||
"hand": TaskIcon.Hand,
|
||||
"handshake": TaskIcon.Handshake,
|
||||
"facesmile": TaskIcon.FaceSmile,
|
||||
"user": TaskIcon.User,
|
||||
"comment": TaskIcon.Comment,
|
||||
"envelope": TaskIcon.Envelope,
|
||||
"file": TaskIcon.File,
|
||||
"paperplane": TaskIcon.PaperPlane,
|
||||
"building": TaskIcon.Building,
|
||||
"flag": TaskIcon.Flag,
|
||||
"bell": TaskIcon.Bell,
|
||||
"clock": TaskIcon.Clock,
|
||||
"image": TaskIcon.Image,
|
||||
}
|
||||
|
||||
export function stringToTask(text: string): Task {
|
||||
const priorityMatch = PRIORITY_GLYPH_RE.exec(text)
|
||||
const importanceMatch = IMPORTANCE_GLYPH_RE.exec(text)
|
||||
const iconMatch = ICON_GLYPH_RE.exec(text)
|
||||
|
||||
const priority: TaskPriority = VALUE_TO_TASK_PRIORITY[priorityMatch?.[1]?.trim() as "1"|"2"|"3"|"4"|"5" ?? "3"]
|
||||
const importance: TaskImportance = VALUE_TO_TASK_IMPORTANCE[importanceMatch?.[1]?.trim() as "1"|"2"|"3"|"4"|"5" ?? "3"]
|
||||
// @ts-ignore
|
||||
const icon: TaskIcon = VALUE_TO_TASK_ICON[iconMatch?.[1]?.toLowerCase()?.trim()] ?? TaskIcon.Circle
|
||||
|
||||
// TODO: Splice so the regexes aren't executed twice
|
||||
text = text.replace(PRIORITY_GLYPH_RE, "")
|
||||
text = text.replace(IMPORTANCE_GLYPH_RE, "")
|
||||
text = text.replace(ICON_GLYPH_RE, "")
|
||||
text = text.trim()
|
||||
|
||||
return {
|
||||
text,
|
||||
status: TaskStatus.Unfinished,
|
||||
priority,
|
||||
importance,
|
||||
icon,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import {Task, TaskImportance, TaskPriority} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
import {ICON_DEFAULT, ICON_GLYPH} from "@/app/[lang]/board/[board]/(page)/(edit)/icon"
|
||||
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",
|
||||
[TaskImportance.Normal]: "3",
|
||||
[TaskImportance.Low]: "4",
|
||||
[TaskImportance.Lowest]: "5",
|
||||
}
|
||||
|
||||
const TASK_PRIORITY_TO_VALUE = {
|
||||
[TaskPriority.Highest]: "1",
|
||||
[TaskPriority.High]: "2",
|
||||
[TaskPriority.Normal]: "3",
|
||||
[TaskPriority.Low]: "4",
|
||||
[TaskPriority.Lowest]: "5",
|
||||
}
|
||||
|
||||
export function taskToString(t: Task): string {
|
||||
let s = ""
|
||||
|
||||
if(t.importance !== IMPORTANCE_DEFAULT) {
|
||||
s += IMPORTANCE_GLYPH
|
||||
s += TASK_IMPORTANCE_TO_VALUE[t.importance]
|
||||
s += " "
|
||||
}
|
||||
|
||||
if(t.priority !== PRIORITY_DEFAULT) {
|
||||
s += PRIORITY_GLYPH
|
||||
s += TASK_PRIORITY_TO_VALUE[t.priority]
|
||||
s += " "
|
||||
}
|
||||
|
||||
if(t.icon !== ICON_DEFAULT) {
|
||||
s += ICON_GLYPH
|
||||
s += t.icon
|
||||
s += " "
|
||||
}
|
||||
|
||||
s += t.text
|
||||
|
||||
return s
|
||||
}
|
|
@ -5,18 +5,18 @@
|
|||
grid-column-gap: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
@media screen and (max-width: 768px) {
|
||||
.boardHeader {
|
||||
grid-template-areas:
|
||||
"left right"
|
||||
"title title"
|
||||
;
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 451px) {
|
||||
@media screen and (min-width: 769px) {
|
||||
.boardHeader {
|
||||
grid-template-areas:
|
||||
"left title right"
|
||||
|
@ -36,14 +36,9 @@
|
|||
|
||||
.titleArea {
|
||||
grid-area: title;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.titleDisplay {
|
||||
padding-top: 0.125em;
|
||||
padding-right: 0.75ex;
|
||||
padding-left: 0.75ex;
|
||||
padding-bottom: calc(0.125em + 2px);
|
||||
}
|
||||
|
||||
.titleInput {
|
|
@ -0,0 +1,38 @@
|
|||
import {BoardHeaderTitle} from "@/app/[lang]/board/[board]/(page)/(header)/BoardHeaderTitle"
|
||||
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 {ToggleEditingButton} from "@/app/[lang]/board/[board]/(page)/(header)/ToggleEditingButton"
|
||||
import {ToggleStarredButton} from "@/app/[lang]/board/[board]/(page)/(header)/ToggleStarredButton"
|
||||
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"
|
||||
|
||||
|
||||
interface BoardHeaderProps {
|
||||
lang: string,
|
||||
className?: string,
|
||||
metadataHook: ReturnType<typeof useBoardMetadataEditor>,
|
||||
layoutHook: ReturnType<typeof useBoardLayoutEditor>,
|
||||
}
|
||||
|
||||
|
||||
export function BoardHeader({lang, className, metadataHook, layoutHook: {columningHook, groupingHook, sortingHook}}: BoardHeaderProps) {
|
||||
return (
|
||||
<header className={cn(style.boardHeader, className)}>
|
||||
<BoardHeaderTitle className={style.titleArea} editorHook={metadataHook}/>
|
||||
<div className={cn(style.buttonsArea, style.leftButtonsArea)}>
|
||||
<NavigateHomeButton lang={lang}/>
|
||||
<ToggleStarredButton lang={lang}/>
|
||||
<ToggleEditingButton lang={lang} metadataHook={metadataHook}/>
|
||||
</div>
|
||||
<div className={cn(style.buttonsArea, style.rightButtonsArea)}>
|
||||
<CycleColumningButton lang={lang} value={columningHook.value} next={columningHook.next}/>
|
||||
<CycleGroupingButton lang={lang} next={groupingHook.next}/>
|
||||
<CycleSortingButton lang={lang} next={sortingHook.next}/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.boardHeaderTitleDisplay {
|
||||
padding: 0 0.75ex 4px;
|
||||
}
|
||||
|
||||
.boardHeaderTitle input {
|
||||
padding: 0 0.75ex 2px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
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"
|
||||
|
||||
|
||||
export function BoardHeaderTitle({className, editorHook}: {className?: string, editorHook: ReturnType<typeof useBoardMetadataEditor>}) {
|
||||
const {isReady, boardState: {title: titleFromState}} = useBoardConsumer()
|
||||
|
||||
if(!isReady) return null;
|
||||
|
||||
let contents;
|
||||
if(editorHook.isEditingMetadata) {
|
||||
contents = (
|
||||
<form
|
||||
className={style.boardHeaderTitleEditor}
|
||||
onSubmit={editorHook.stopEditingMetadata}
|
||||
>
|
||||
<input
|
||||
className={style.boardHeaderTitleInput}
|
||||
type={"text"}
|
||||
placeholder={"Titolo"}
|
||||
onChange={(e) => editorHook.setTitleFromEditor(e.target.value)}
|
||||
value={editorHook.titleFromEditor}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
else {
|
||||
contents = (
|
||||
<div
|
||||
className={style.boardHeaderTitleDisplay}
|
||||
>
|
||||
{titleFromState}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<h1 className={cn(style.boardHeaderTitle, className)}>
|
||||
{contents}
|
||||
</h1>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
import {COLUMNING_MODE_TO_ICON, ColumningMode} from "../(view)/(columning)"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
|
||||
|
||||
export function CycleColumningButton({lang, value, next}: {lang: string, value: ColumningMode, next: () => void}) {
|
||||
const {isReady} = useBoardConsumer()
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
|
||||
if(!isReady) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
title={t("cycleColumningButtonTitle")}
|
||||
onClick={next}
|
||||
>
|
||||
<FontAwesomeIcon icon={COLUMNING_MODE_TO_ICON[value]}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
import {faObjectGroup} from "@fortawesome/free-solid-svg-icons"
|
||||
|
||||
|
||||
export function CycleGroupingButton({lang, next}: {lang: string, next: () => void}) {
|
||||
const {isReady} = useBoardConsumer()
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
|
||||
if(!isReady) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
title={t("cycleGroupingButtonTitle")}
|
||||
onClick={next}
|
||||
>
|
||||
<FontAwesomeIcon icon={faObjectGroup}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
import {faArrowDownWideShort} from "@fortawesome/free-solid-svg-icons"
|
||||
|
||||
|
||||
export function CycleSortingButton({lang, next}: {lang: string, next: () => void}) {
|
||||
const {isReady} = useBoardConsumer()
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
|
||||
if(!isReady) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
title={t("cycleSortingButtonTitle")}
|
||||
onClick={next}
|
||||
>
|
||||
<FontAwesomeIcon icon={faArrowDownWideShort}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {faHouse} from "@fortawesome/free-solid-svg-icons"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {useCallback} from "react"
|
||||
|
||||
|
||||
export function NavigateHomeButton({lang}: {lang: string}) {
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
const router = useRouter()
|
||||
const goHome = useCallback(() => router.push("/"), [router])
|
||||
|
||||
return (
|
||||
<button title={t("navigateHomeButtonTitle")} onClick={goHome}>
|
||||
<FontAwesomeIcon icon={faHouse}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
import {useBoardMetadataEditor} from "@/app/[lang]/board/[board]/(page)/useBoardMetadataEditor"
|
||||
import {faFloppyDisk, faPencil} from "@fortawesome/free-solid-svg-icons"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
|
||||
|
||||
export function ToggleEditingButton({lang, metadataHook}: {lang: string, metadataHook: ReturnType<typeof useBoardMetadataEditor>}) {
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
const {isReady} = useBoardConsumer()
|
||||
|
||||
if(!isReady) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
title={metadataHook.isEditingMetadata ? t("stopEditingButtonTitle") : t("startEditingButtonTitle")}
|
||||
onClick={metadataHook.toggleEditingMetadata}
|
||||
>
|
||||
<FontAwesomeIcon icon={metadataHook.isEditingMetadata ? faFloppyDisk : faPencil}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {useStarredConsumer} from "@/app/[lang]/(layout)/(contextStarred)"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
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"
|
||||
|
||||
|
||||
export function ToggleStarredButton({lang}: {lang: string}) {
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
const {boardName} = useBoardConsumer()
|
||||
const {isStarred, toggleStarred} = useStarredConsumer()
|
||||
const thisIsStarred = isStarred(boardName)
|
||||
|
||||
return (
|
||||
<button
|
||||
title={thisIsStarred ? t("removeStarredButtonTitle") : t("addStarredButtonTitle")}
|
||||
onClick={() => toggleStarred(boardName)}
|
||||
>
|
||||
<FontAwesomeIcon icon={thisIsStarred ? faStarSolid : faStarRegular}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export enum ColumningMode {
|
||||
MultiColumn,
|
||||
SingleColumn,
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import {ColumningMode} from "@/app/[lang]/board/[board]/(page)/(view)/(columning)/ColumningMode"
|
||||
import {faTableColumns, faTableList} from "@fortawesome/free-solid-svg-icons"
|
||||
|
||||
|
||||
export const COLUMNING_MODE_TO_ICON = {
|
||||
[ColumningMode.SingleColumn]: faTableList,
|
||||
[ColumningMode.MultiColumn]: faTableColumns,
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export {ColumningMode} from "./ColumningMode"
|
||||
export {COLUMNING_MODE_TO_ICON} from "./columningModeToIcon"
|
|
@ -0,0 +1,6 @@
|
|||
export enum GroupingMode {
|
||||
ByImportance,
|
||||
ByPriority,
|
||||
ByStatus,
|
||||
ByIcon,
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import {TaskIcon, TaskImportance, TaskPriority, TaskStatus} 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"
|
||||
|
||||
const TASK_IMPORTANCE_TO_VALUE = {
|
||||
[TaskImportance.Highest]: 1,
|
||||
[TaskImportance.High]: 2,
|
||||
[TaskImportance.Normal]: 3,
|
||||
[TaskImportance.Low]: 4,
|
||||
[TaskImportance.Lowest]: 5,
|
||||
}
|
||||
|
||||
const TASK_PRIORITY_TO_VALUE = {
|
||||
[TaskPriority.Highest]: 1,
|
||||
[TaskPriority.High]: 2,
|
||||
[TaskPriority.Normal]: 3,
|
||||
[TaskPriority.Low]: 4,
|
||||
[TaskPriority.Lowest]: 5,
|
||||
}
|
||||
|
||||
const TASK_STATUS_TO_VALUE = {
|
||||
[TaskStatus.Unfinished]: 1,
|
||||
[TaskStatus.InProgress]: 2,
|
||||
[TaskStatus.Complete]: 3,
|
||||
}
|
||||
|
||||
const TASK_ICON_TO_VALUE = {
|
||||
[TaskIcon.Bookmark]: 1,
|
||||
[TaskIcon.Circle]: 2,
|
||||
[TaskIcon.Square]: 3,
|
||||
[TaskIcon.Heart]: 4,
|
||||
[TaskIcon.Star]: 5,
|
||||
[TaskIcon.Sun]: 6,
|
||||
[TaskIcon.Moon]: 7,
|
||||
[TaskIcon.Eye]: 8,
|
||||
[TaskIcon.Hand]: 9,
|
||||
[TaskIcon.Handshake]: 10,
|
||||
[TaskIcon.FaceSmile]: 11,
|
||||
[TaskIcon.User]: 12,
|
||||
[TaskIcon.Comment]: 13,
|
||||
[TaskIcon.Envelope]: 14,
|
||||
[TaskIcon.File]: 15,
|
||||
[TaskIcon.PaperPlane]: 16,
|
||||
[TaskIcon.Building]: 17,
|
||||
[TaskIcon.Flag]: 18,
|
||||
[TaskIcon.Bell]: 19,
|
||||
[TaskIcon.Clock]: 20,
|
||||
[TaskIcon.Image]: 21,
|
||||
}
|
||||
|
||||
export const GROUPING_MODE_TO_GROUP_SORTER_FUNCTION = {
|
||||
[GroupingMode.ByImportance]: function sortGroupsByImportance(a: TaskGroup<TaskImportance>, b: TaskGroup<TaskImportance>): number {
|
||||
return TASK_IMPORTANCE_TO_VALUE[a.k] - TASK_IMPORTANCE_TO_VALUE[b.k]
|
||||
},
|
||||
[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.ByIcon]: function sortGroupsByIcon(a: TaskGroup<TaskIcon>, b: TaskGroup<TaskIcon>): number {
|
||||
return TASK_ICON_TO_VALUE[a.k] - TASK_ICON_TO_VALUE[b.k]
|
||||
},
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
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 {
|
||||
return t[1].importance
|
||||
},
|
||||
[GroupingMode.ByPriority]: function groupTasksByPriority(t: TaskWithId): TaskPriority {
|
||||
return t[1].priority
|
||||
},
|
||||
[GroupingMode.ByStatus]: function groupTasksByStatus(t: TaskWithId): TaskStatus {
|
||||
return t[1].status
|
||||
},
|
||||
[GroupingMode.ByIcon]: function groupTasksByIcon(t: TaskWithId): TaskIcon {
|
||||
return t[1].icon
|
||||
},
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
import {useClientTranslation} from "@/app/(i18n)/client"
|
||||
import {TASK_ICON_TO_FONTAWESOME_REGULAR, TaskIcon, TaskImportance, TaskPriority, TaskStatus} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
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 {ReactNode} from "react"
|
||||
|
||||
|
||||
export interface TitleComponentProps {
|
||||
lang: string,
|
||||
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")
|
||||
|
||||
switch(k) {
|
||||
case TaskImportance.Highest:
|
||||
return (
|
||||
<span className={taskImportanceStyle.taskImportanceHighest}>
|
||||
{t("columnHeaderTaskImportanceHighest")}
|
||||
</span>
|
||||
)
|
||||
case TaskImportance.High:
|
||||
return (
|
||||
<span className={taskImportanceStyle.taskImportanceHigh}>
|
||||
{t("columnHeaderTaskImportanceHigh")}
|
||||
</span>
|
||||
)
|
||||
case TaskImportance.Normal:
|
||||
return (
|
||||
<span className={taskImportanceStyle.taskImportanceNormal}>
|
||||
{t("columnHeaderTaskImportanceNormal")}
|
||||
</span>
|
||||
)
|
||||
case TaskImportance.Low:
|
||||
return (
|
||||
<span className={taskImportanceStyle.taskImportanceLow}>
|
||||
{t("columnHeaderTaskImportanceLow")}
|
||||
</span>
|
||||
)
|
||||
case TaskImportance.Lowest:
|
||||
return (
|
||||
<span className={taskImportanceStyle.taskImportanceLowest}>
|
||||
{t("columnHeaderTaskImportanceLowest")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
[GroupingMode.ByPriority]: function TitleFromPriority({lang, k}: { lang: string, k: TaskPriority }): ReactNode {
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
|
||||
switch(k) {
|
||||
case TaskPriority.Highest:
|
||||
return (
|
||||
<span className={taskPriorityStyle.taskPriorityHighestColumnHeader}>
|
||||
{t("columnHeaderTaskPriorityHighest")}
|
||||
</span>
|
||||
)
|
||||
case TaskPriority.High:
|
||||
return (
|
||||
<span className={taskPriorityStyle.taskPriorityHighColumnHeader}>
|
||||
{t("columnHeaderTaskPriorityHigh")}
|
||||
</span>
|
||||
)
|
||||
case TaskPriority.Normal:
|
||||
return (
|
||||
<span className={taskPriorityStyle.taskPriorityNormalColumnHeader}>
|
||||
{t("columnHeaderTaskPriorityNormal")}
|
||||
</span>
|
||||
)
|
||||
case TaskPriority.Low:
|
||||
return (
|
||||
<span className={taskPriorityStyle.taskPriorityLowColumnHeader}>
|
||||
{t("columnHeaderTaskPriorityLow")}
|
||||
</span>
|
||||
)
|
||||
case TaskPriority.Lowest:
|
||||
return (
|
||||
<span className={taskPriorityStyle.taskPriorityLowestColumnHeader}>
|
||||
{t("columnHeaderTaskPriorityLowest")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
[GroupingMode.ByStatus]: function TitleFromStatus({lang, k}: { lang: string, k: TaskStatus }): ReactNode {
|
||||
const {t} = useClientTranslation(lang, "board")
|
||||
|
||||
switch(k) {
|
||||
case TaskStatus.Unfinished:
|
||||
return (
|
||||
<span className={taskStatusStyle.taskStatusUnfinishedColumnHeader}>
|
||||
{t("columnHeaderTaskStatusUnfinished")}
|
||||
</span>
|
||||
)
|
||||
case TaskStatus.InProgress:
|
||||
return (
|
||||
<span className={taskStatusStyle.taskStatusInProgressColumnHeader}>
|
||||
{t("columnHeaderTaskStatusInProgress")}
|
||||
</span>
|
||||
)
|
||||
case TaskStatus.Complete:
|
||||
return (
|
||||
<span className={taskStatusStyle.taskStatusCompleteColumnHeader}>
|
||||
{t("columnHeaderTaskStatusComplete")}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
[GroupingMode.ByIcon]: function TitleFromIcon({k}: { lang: string, k: TaskIcon }): ReactNode {
|
||||
return (
|
||||
<span>
|
||||
<FontAwesomeIcon icon={TASK_ICON_TO_FONTAWESOME_REGULAR[k]}/>
|
||||
|
||||
{TaskIcon[k]}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export {GroupingMode} from "./GroupingMode"
|
||||
export {GROUPING_MODE_TO_TASK_GROUPER_FUNCTION} from "./groupingModeToTaskGrouperFunction"
|
||||
export {GROUPING_MODE_TO_GROUP_SORTER_FUNCTION} from "./groupingModeToGroupSorterFunction"
|
||||
export {GROUPING_MODE_TO_TITLE_COMPONENT} from "./groupingModeToTitleComponent"
|
|
@ -0,0 +1,7 @@
|
|||
export enum SortingMode {
|
||||
ByPriority,
|
||||
ByImportance,
|
||||
ByIcon,
|
||||
ByText,
|
||||
ByStatus,
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export {SortingMode} from "./SortingMode"
|
|
@ -0,0 +1,33 @@
|
|||
import {SortingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(sorting)/SortingMode"
|
||||
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
|
||||
|
||||
|
||||
export function getTaskSorter(sortingModes: SortingMode[]) {
|
||||
return (a: TaskWithId, b: TaskWithId) => {
|
||||
for(const sortingMode of sortingModes) {
|
||||
const result = SORTING_MODE_TO_SORTING_FUNCTION[sortingMode](a, b)
|
||||
if(result !== 0) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const SORTING_MODE_TO_SORTING_FUNCTION = {
|
||||
[SortingMode.ByText]: function sortTasksByText(a: TaskWithId, b: TaskWithId) {
|
||||
return a[1].text.localeCompare(b[1].text)
|
||||
},
|
||||
[SortingMode.ByIcon]: function sortTasksByIcon(a: TaskWithId, b: TaskWithId) {
|
||||
return a[1].icon - b[1].icon
|
||||
},
|
||||
[SortingMode.ByImportance]: function sortTasksByImportance(a: TaskWithId, b: TaskWithId) {
|
||||
return a[1].importance - b[1].importance
|
||||
},
|
||||
[SortingMode.ByPriority]: function sortTasksByPriority(a: TaskWithId, b: TaskWithId) {
|
||||
return a[1].priority - b[1].priority
|
||||
},
|
||||
[SortingMode.ByStatus]: function sortTasksByStatus(a: TaskWithId, b: TaskWithId) {
|
||||
return a[1].status - b[1].status
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import {TaskWithId} from "@/app/[lang]/board/[board]/(page)/(view)/(task)/TaskWithId"
|
||||
|
||||
|
||||
export type TaskGroup<K> = {
|
||||
k: K,
|
||||
tasks: TaskWithId[],
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
|
||||
|
||||
export type TaskWithId = [string, Task]
|
|
@ -0,0 +1,26 @@
|
|||
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,
|
||||
})
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
.taskImportanceHighest {
|
||||
--bhsl-current-hue: 15deg;
|
||||
--bhsl-current-saturation: 98%;
|
||||
--bhsl-current-lightness: 75%;
|
||||
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.taskImportanceHigh {
|
||||
--bhsl-current-hue: 260deg;
|
||||
--bhsl-current-saturation: 66%;
|
||||
--bhsl-current-lightness: 72%;
|
||||
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.taskImportanceNormal {
|
||||
--bhsl-current-hue: 212deg;
|
||||
--bhsl-current-saturation: 100%;
|
||||
--bhsl-current-lightness: 81%;
|
||||
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.taskImportanceLow {
|
||||
--bhsl-current-hue: 168deg;
|
||||
--bhsl-current-saturation: 22%;
|
||||
--bhsl-current-lightness: 61%;
|
||||
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.taskImportanceLowest {
|
||||
--bhsl-current-hue: 120deg;
|
||||
--bhsl-current-saturation: 30%;
|
||||
--bhsl-current-lightness: 30%;
|
||||
|
||||
font-weight: 300;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
.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;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
@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;
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
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 {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
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")
|
||||
const {webSocketState, webSocketBackoffMs, boardState} = useBoardConsumer()
|
||||
|
||||
switch(webSocketState) {
|
||||
case undefined:
|
||||
return <SplashWithIcon
|
||||
icon={<FontAwesomeIcon size={"4x"} icon={faGear} beatFade/>}
|
||||
text={t("boardPreparing")}
|
||||
className={className}
|
||||
/>
|
||||
case WebSocket.CONNECTING:
|
||||
return <SplashWithIcon
|
||||
icon={<FontAwesomeIcon size={"4x"} icon={faLink} beatFade/>}
|
||||
text={t("boardConnecting")}
|
||||
className={className}
|
||||
/>
|
||||
case WebSocket.OPEN:
|
||||
if(Object.keys(boardState.tasksById).length === 0) {
|
||||
return <SplashWithIcon
|
||||
icon={<FontAwesomeIcon size={"4x"} icon={faAsterisk}/>}
|
||||
text={t("boardEmpty")}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
else {
|
||||
return <BoardViewer
|
||||
lang={lang}
|
||||
columning={columning}
|
||||
sorting={sorting}
|
||||
grouping={grouping}
|
||||
setEditorInput={setEditorInput}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
case WebSocket.CLOSING:
|
||||
return <SplashWithIcon
|
||||
icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash} beatFade/>}
|
||||
text={t("boardDisconnecting")}
|
||||
className={className}
|
||||
/>
|
||||
case WebSocket.CLOSED:
|
||||
return <SplashWithIcon
|
||||
icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash}/>}
|
||||
text={t("boardDisconnected", { retryingInSeconds: Math.ceil(webSocketBackoffMs / 1000).toString() })}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
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 {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 {useBoardTasksArranger} from "@/app/[lang]/board/[board]/(page)/(view)/useBoardTasksArranger"
|
||||
import cn from "classnames"
|
||||
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);
|
||||
|
||||
return (
|
||||
<main className={cn(className, {
|
||||
[style.boardMainTaskGroups]: true,
|
||||
[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}/>)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function BoardViewerColumn({lang, grouping, taskGroup, setEditorInput}: {lang: string, 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}/>
|
||||
</h3>
|
||||
<div className={style.boardColumnContents}>
|
||||
{taskGroup.tasks.map((task: TaskWithId) => <TaskViewer lang={lang} task={task} key={task[0]} setEditorInput={setEditorInput}/>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
.boardLoading {
|
||||
.splashWithIcon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
|
@ -0,0 +1,17 @@
|
|||
import cn from "classnames"
|
||||
import {ReactNode} from "react"
|
||||
import style from "./SplashWithIcon.module.css"
|
||||
|
||||
|
||||
export function SplashWithIcon({text, icon, className}: {text: string, icon: ReactNode, className?: string}) {
|
||||
return (
|
||||
<div className={cn(style.splashWithIcon, className)}>
|
||||
<div>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
.taskViewer {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
min-width: 240px;
|
||||
min-height: 48px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taskIcon {
|
||||
display: flex;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.taskViewerBack {
|
||||
flex-direction: row-reverse;
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
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, MouseEvent, 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)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn({
|
||||
"panel": true,
|
||||
[style.taskViewer]: true,
|
||||
[style.taskViewerFront]: !isFlipped,
|
||||
[style.taskViewerBack]: isFlipped,
|
||||
}, taskClassNames(task[1]))}
|
||||
onClick={() => setFlipped(prev => !prev)}
|
||||
>
|
||||
{isFlipped ? <TaskViewerBack lang={lang} task={task} setEditorInput={setEditorInput}/> : <TaskViewerFront lang={lang} task={task}/>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskViewerFront({lang, task}: {lang: string, task: TaskWithId}) {
|
||||
const {sendRequest} = useBoardConsumer()
|
||||
|
||||
const toggleStatus = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
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} 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: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setEditorInput(taskToString(task[1]))
|
||||
const request: DeleteTaskBoardRequest = {"Task": [task[0], null]};
|
||||
sendRequest(request)
|
||||
}, [task, setEditorInput, sendRequest])
|
||||
|
||||
return <>
|
||||
<div className={style.taskButtons}>
|
||||
<button title={t("taskButtonRecreate")} onClick={recreateTask}>
|
||||
<FontAwesomeIcon size={"sm"} icon={faTrashArrowUp}/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
|
||||
import {GROUPING_MODE_TO_GROUP_SORTER_FUNCTION, GROUPING_MODE_TO_TASK_GROUPER_FUNCTION, GroupingMode} from "@/app/[lang]/board/[board]/(page)/(view)/(grouping)"
|
||||
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[]) {
|
||||
const taskGroups = useMemo(() => {
|
||||
const taskGrouperFunction = GROUPING_MODE_TO_TASK_GROUPER_FUNCTION[grouping]
|
||||
const keyedTaskGroups: {[t: string | number]: TaskWithId[]} = {}
|
||||
|
||||
Object.entries(tasksById).forEach((t: TaskWithId) => {
|
||||
const group = taskGrouperFunction(t)
|
||||
const array = keyedTaskGroups[group] ?? []
|
||||
keyedTaskGroups[group] = [...array, t]
|
||||
})
|
||||
|
||||
const taskSorterFunction = getTaskSorter(sorting)
|
||||
Object.values(keyedTaskGroups).forEach((ta: TaskWithId[]) => {
|
||||
ta.sort(taskSorterFunction)
|
||||
})
|
||||
|
||||
const groupSorterFunction = GROUPING_MODE_TO_GROUP_SORTER_FUNCTION[grouping]
|
||||
// FIXME: The typing of this function is completely messed up.
|
||||
const taskGroups: any = Object.entries(keyedTaskGroups).map(([k, tasks]) => ({k, tasks}))
|
||||
taskGroups.sort(groupSorterFunction)
|
||||
return taskGroups as TaskGroup<string | number>[]
|
||||
|
||||
}, [tasksById, grouping, sorting])
|
||||
|
||||
return {
|
||||
taskGroups,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import {useCycler} from "@/app/(utils)/useCycler"
|
||||
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 {useMemo} from "react"
|
||||
import useLocalStorage from "use-local-storage"
|
||||
|
||||
|
||||
export function useBoardLayoutEditor() {
|
||||
const {boardName} = useBoardConsumer()
|
||||
|
||||
const localStorageKeyColumning = useMemo(() => `TODOBLUE_${boardName}_LAYOUT`, [boardName])
|
||||
const localStorageKeyGrouping = useMemo(() => `TODOBLUE_${boardName}_GROUPING`, [boardName])
|
||||
const localStorageKeySorting = useMemo(() => `TODOBLUE_${boardName}_SORTING`, [boardName])
|
||||
|
||||
const columningHook = useCycler(useLocalStorage<number | undefined>(localStorageKeyColumning, undefined), [
|
||||
ColumningMode.SingleColumn,
|
||||
ColumningMode.MultiColumn,
|
||||
])
|
||||
const groupingHook = useCycler(useLocalStorage<number | undefined>(localStorageKeyGrouping, undefined), [
|
||||
GroupingMode.ByPriority,
|
||||
GroupingMode.ByImportance,
|
||||
GroupingMode.ByStatus,
|
||||
GroupingMode.ByIcon,
|
||||
])
|
||||
const sortingHook = useCycler(useLocalStorage<number | undefined>(localStorageKeySorting, undefined), [
|
||||
[
|
||||
SortingMode.ByStatus,
|
||||
SortingMode.ByPriority,
|
||||
SortingMode.ByImportance,
|
||||
SortingMode.ByIcon,
|
||||
SortingMode.ByText,
|
||||
],
|
||||
[
|
||||
SortingMode.ByStatus,
|
||||
SortingMode.ByImportance,
|
||||
SortingMode.ByPriority,
|
||||
SortingMode.ByIcon,
|
||||
SortingMode.ByText,
|
||||
],
|
||||
[
|
||||
SortingMode.ByText,
|
||||
]
|
||||
])
|
||||
|
||||
return {columningHook, groupingHook, sortingHook}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import {TitleBoardRequest} from "@/app/[lang]/board/[board]/(api)/(request)"
|
||||
import {useBoardConsumer} from "@/app/[lang]/board/[board]/(layout)/(contextBoard)"
|
||||
import {useCallback, useState} from "react"
|
||||
|
||||
|
||||
export function useBoardMetadataEditor() {
|
||||
const {sendRequest, boardState: {title: titleFromState}} = useBoardConsumer()
|
||||
const [isEditingMetadata, setEditingMetadata] = useState<boolean>(false)
|
||||
const [titleFromEditor, setTitleFromEditor] = useState<string>(titleFromState)
|
||||
|
||||
const startEditingMetadata = useCallback(() => {
|
||||
console.debug("[useEditableTitle] Starting title edit...");
|
||||
setEditingMetadata(true);
|
||||
setTitleFromEditor(titleFromState);
|
||||
}, [titleFromState])
|
||||
|
||||
const stopEditingMetadata = useCallback(() => {
|
||||
console.debug("[useEditableTitle] Ending title edit...");
|
||||
setEditingMetadata(false);
|
||||
if(titleFromEditor.length > 0) {
|
||||
console.debug("[useEditableTitle] Sending title change request...");
|
||||
const request: TitleBoardRequest = {"Title": titleFromEditor}
|
||||
sendRequest(request)
|
||||
}
|
||||
}, [sendRequest, titleFromEditor])
|
||||
|
||||
const toggleEditingMetadata = useCallback(() => {
|
||||
return isEditingMetadata ? stopEditingMetadata() : startEditingMetadata()
|
||||
}, [isEditingMetadata, stopEditingMetadata, startEditingMetadata])
|
||||
|
||||
return {isEditingMetadata, startEditingMetadata, stopEditingMetadata, toggleEditingMetadata, titleFromEditor, setTitleFromEditor}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import {stringToTask} from "@/app/[lang]/board/[board]/(page)/(edit)/stringToTask"
|
||||
import {useMemo, useState} from "react"
|
||||
|
||||
|
||||
export function useTaskEditor() {
|
||||
const [input, setInput] = useState<string>("")
|
||||
const task = useMemo(() => stringToTask(input), [input])
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
task,
|
||||
}
|
||||
}
|
|
@ -1,175 +0,0 @@
|
|||
import {useStarredConsumer} from "../../(layout)/(contextStarred)"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {ReactNode, useCallback} from "react"
|
||||
import style from "./BoardHeader.module.css"
|
||||
import {useManagedBoard} from "@/app/[lang]/board/[board]/BoardManager"
|
||||
import {faArrowDownWideShort, faHouse, faPencil, faObjectGroup, faTableColumns, faStar as faStarSolid} from "@fortawesome/free-solid-svg-icons"
|
||||
import {faStar as faStarRegular} from "@fortawesome/free-regular-svg-icons"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
import cn from "classnames"
|
||||
|
||||
|
||||
export function BoardHeader({className}: {className?: string}) {
|
||||
const {isEditingTitle} = useManagedBoard();
|
||||
|
||||
return (
|
||||
<header className={cn(style.boardHeader, className)}>
|
||||
<TitleArea>
|
||||
{isEditingTitle ? <TitleInput/> : <TitleDisplay/>}
|
||||
</TitleArea>
|
||||
<LeftButtonsArea>
|
||||
<HomeButton/>
|
||||
<StarButton/>
|
||||
<EditTitleButton/>
|
||||
</LeftButtonsArea>
|
||||
<RightButtonsArea>
|
||||
<ToggleSingleColumnButton/>
|
||||
<CycleGroupButton/>
|
||||
<CycleSortButton/>
|
||||
</RightButtonsArea>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleArea({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<h1 className={style.titleArea}>
|
||||
{children}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleInput() {
|
||||
const {editTitle, setEditTitle, stopEditingTitle, webSocketState} = useManagedBoard()
|
||||
|
||||
if(webSocketState !== WebSocket.OPEN) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={stopEditingTitle}>
|
||||
<input
|
||||
className={style.titleInput}
|
||||
type={"text"}
|
||||
placeholder={"Titolo"}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
value={editTitle}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function TitleDisplay() {
|
||||
const {title, webSocketState} = useManagedBoard()
|
||||
|
||||
if(webSocketState !== WebSocket.OPEN) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={style.titleDisplay}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeftButtonsArea({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(style.buttonsArea, style.leftButtonsArea)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeButton() {
|
||||
const router = useRouter()
|
||||
const goHome = useCallback(() => router.push("/"), [router])
|
||||
|
||||
return (
|
||||
<button title={"Pagina principale"} onClick={goHome}>
|
||||
<FontAwesomeIcon icon={faHouse}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StarButton() {
|
||||
const {name} = useManagedBoard()
|
||||
const {starred, addStarred, removeStarred} = useStarredConsumer()
|
||||
const isStarred = starred.indexOf(name) >= 0
|
||||
|
||||
const toggleStarred = useCallback(() => isStarred ? removeStarred(name) : addStarred(name), [name, isStarred, addStarred, removeStarred])
|
||||
|
||||
return (
|
||||
<button title={"Stella tabellone"} onClick={toggleStarred}>
|
||||
<FontAwesomeIcon icon={isStarred ? faStarSolid : faStarRegular}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function EditTitleButton() {
|
||||
const {webSocketState, toggleEditingTitle} = useManagedBoard()
|
||||
|
||||
if(webSocketState != WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button title={"Modifica titolo"} onClick={toggleEditingTitle}>
|
||||
<FontAwesomeIcon icon={faPencil}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function RightButtonsArea({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<div className={cn(style.buttonsArea, style.rightButtonsArea)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSingleColumnButton() {
|
||||
const {webSocketState, setSingleColumn} = useManagedBoard()
|
||||
|
||||
if(webSocketState != WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button title={"Cambia numero di colonne"} onClick={() => setSingleColumn(prev => !prev)}>
|
||||
<FontAwesomeIcon icon={faTableColumns}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CycleGroupButton() {
|
||||
const {webSocketState, nextGrouper} = useManagedBoard()
|
||||
|
||||
if(webSocketState != WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button title={"Cambia raggruppamento"} onClick={nextGrouper}>
|
||||
<FontAwesomeIcon icon={faObjectGroup}/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CycleSortButton() {
|
||||
const {webSocketState, nextSorter} = useManagedBoard()
|
||||
|
||||
if(webSocketState != WebSocket.OPEN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button title={"Cambia ordinamento"} onClick={nextSorter}>
|
||||
<FontAwesomeIcon icon={faArrowDownWideShort}/>
|
||||
</button>
|
||||
)
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import {BoardMainIcon} from "@/app/[lang]/board/[board]/BoardMainIcon"
|
||||
import {BoardMainTaskGroups} from "@/app/[lang]/board/[board]/BoardMainTaskGroups"
|
||||
import {useManagedBoard} from "@/app/[lang]/board/[board]/BoardManager"
|
||||
import {faLink, faLinkSlash, faGear, faAsterisk} from "@fortawesome/free-solid-svg-icons"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
|
||||
|
||||
export function BoardMain({className}: {className?: string}) {
|
||||
const {webSocketState, webSocketBackoffMs, taskGroups} = useManagedBoard()
|
||||
|
||||
switch(webSocketState) {
|
||||
case undefined:
|
||||
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faGear} beatFade/>} text={"Caricamento..."} className={className}/>
|
||||
case WebSocket.CONNECTING:
|
||||
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLink} beatFade/>} text={"Connessione..."} className={className}/>
|
||||
case WebSocket.OPEN:
|
||||
if(taskGroups.length === 0) {
|
||||
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faAsterisk}/>} text={"Nulla da visualizzare"} className={className}/>
|
||||
}
|
||||
else {
|
||||
return <BoardMainTaskGroups className={className}/>
|
||||
}
|
||||
case WebSocket.CLOSING:
|
||||
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash} beatFade/>} text={"Disconnessione..."} className={className}/>
|
||||
case WebSocket.CLOSED:
|
||||
return <BoardMainIcon icon={<FontAwesomeIcon size={"4x"} icon={faLinkSlash}/>} text={`Disconnesso, riconnessione tra ${webSocketBackoffMs}ms`} className={className}/>
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
import cn from "classnames"
|
||||
import {ReactNode} from "react"
|
||||
import style from "./BoardMainIcon.module.css"
|
||||
|
||||
|
||||
export function BoardMainIcon({text, icon, className}: {text: string, icon: ReactNode, className?: string}) {
|
||||
return (
|
||||
<main className={cn(style.boardLoading, className)}>
|
||||
<div>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
{text}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
import {BoardMainIcon} from "@/app/[lang]/board/[board]/BoardMainIcon"
|
||||
import {useManagedBoard} from "@/app/[lang]/board/[board]/BoardManager"
|
||||
import {TaskDisplay} from "@/app/[lang]/board/[board]/TaskDisplay"
|
||||
import {TaskGroup} from "@/app/[lang]/board/[board]/useBoardTaskArranger"
|
||||
import cn from "classnames"
|
||||
import style from "./BoardMainTaskGroups.module.css"
|
||||
|
||||
|
||||
export function BoardMainTaskGroups({className}: {className?: string}) {
|
||||
const {taskGroups, isSingleColumn} = useManagedBoard()
|
||||
|
||||
return (
|
||||
<main className={cn(className, {
|
||||
[style.boardMainTaskGroups]: true,
|
||||
[style.boardMainTaskGroupsMultiColumn]: !isSingleColumn,
|
||||
[style.boardMainTaskGroupsSingleColumn]: isSingleColumn,
|
||||
})}>
|
||||
{taskGroups.map((tg) => <BoardColumn taskGroup={tg} key={tg.key}/>)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function BoardColumn({taskGroup}: {taskGroup: TaskGroup}) {
|
||||
return (
|
||||
<div className={style.boardColumn}>
|
||||
<h3>
|
||||
{taskGroup.name}
|
||||
</h3>
|
||||
<div className={style.boardColumnContents}>
|
||||
{taskGroup.tasks.map(task => <TaskDisplay task={task} key={task.id}/>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import {useBoard, UseBoardReturns} from "@/app/[lang]/board/[board]/useBoard"
|
||||
import {createContext, ReactNode, useContext} from "react"
|
||||
|
||||
|
||||
/**
|
||||
* **Context** where {@link UseBoardReturns} are stored in.
|
||||
*/
|
||||
const BoardContext = createContext<UseBoardReturns | null>(null);
|
||||
|
||||
/**
|
||||
* **Component** handling everything displayed in a board's page and allowing children to access it via {@link useManagedBoard}.
|
||||
*
|
||||
* @param name The name of the board to connect to.
|
||||
* @param children The nodes to which provide access to {@link useManagedBoard}.
|
||||
* @constructor
|
||||
*/
|
||||
export function BoardManager({name, children}: {name: string, children: ReactNode}) {
|
||||
const context = useBoard(name);
|
||||
|
||||
return (
|
||||
<BoardContext.Provider value={context}>
|
||||
{children}
|
||||
</BoardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* **Hook** allowing components to access values managed by {@link useBoard}.
|
||||
*/
|
||||
export function useManagedBoard(): UseBoardReturns {
|
||||
const context = useContext(BoardContext);
|
||||
|
||||
if(context === null) {
|
||||
console.error("[useBoardManager] Was used outside of a BoardContext!")
|
||||
throw Error()
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import {useManagedBoard} from "@/app/[lang]/board/[board]/BoardManager"
|
||||
import {FormEvent, useCallback} from "react"
|
||||
|
||||
|
||||
export function BoardTaskEditForm() {
|
||||
const {editedTaskText, setEditedTaskText, editedTask, send} = useManagedBoard()
|
||||
|
||||
const submitTask = useCallback((e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
send({"Task": [null, editedTask]});
|
||||
setEditedTaskText("")
|
||||
}, [send, editedTask])
|
||||
|
||||
return (
|
||||
<form onSubmit={submitTask}>
|
||||
<input type={"text"} placeholder={"What to do...?"} value={editedTaskText} onChange={(e) => setEditedTaskText(e.target.value)}/>
|
||||
</form>
|
||||
)
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
.editorForm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.editorTextInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editorSubmitButton {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
import {useManagedBoard} from "@/app/[lang]/board/[board]/BoardManager"
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
|
||||
import cn from "classnames"
|
||||
import {FormEvent, useCallback} from "react"
|
||||
import style from "./BoardTaskEditor.module.css"
|
||||
import {faAdd} from "@fortawesome/free-solid-svg-icons"
|
||||
|
||||
|
||||
export function BoardTaskEditor({className}: {className?: string}) {
|
||||
const {editedTask, send, setEditedTaskText, webSocketState} = useManagedBoard()
|
||||
|
||||
const submitTask = useCallback((e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
send({"Task": [null, editedTask]});
|
||||
setEditedTaskText("")
|
||||
}, [send, editedTask])
|
||||
|
||||
if(webSocketState != WebSocket.OPEN) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submitTask} className={cn(style.editorForm, className)}>
|
||||
<EditorTextInput/>
|
||||
<EditorSubmitButton/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorTextInput() {
|
||||
const {editedTaskText, setEditedTaskText} = useManagedBoard();
|
||||
|
||||
return (
|
||||
<input
|
||||
type={"text"}
|
||||
placeholder={"What to do...?"}
|
||||
value={editedTaskText}
|
||||
onChange={(e) => setEditedTaskText(e.target.value)}
|
||||
className={style.editorTextInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EditorSubmitButton() {
|
||||
return (
|
||||
<button className={style.editorSubmitButton} title={"Crea"}>
|
||||
<FontAwesomeIcon icon={faAdd}/>
|
||||
</button>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue