1
Fork 0
mirror of https://github.com/Steffo99/todocolors.git synced 2024-11-28 19:14:29 +00:00

Refactor the whole frontend into something with probably more sense

This commit is contained in:
Steffo 2023-08-11 05:04:49 +02:00
parent 948abb30d0
commit d7f704ea49
Signed by: steffo
GPG key ID: 2A24051445686895
115 changed files with 1870 additions and 1536 deletions

View 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>

View file

@ -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?"
} }

View file

@ -35,7 +35,7 @@ export function useClientTranslation(lang: string, ns: string) {
) )
return { 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, i18n: instance,
} }
} }

View file

@ -0,0 +1,3 @@
export {useKebabifier} from "./useKebabifier"
export {useLowerKebabifier} from "./useLowerKebabifier"
export {useUpperKebabifier} from "./useUpperKebabifier"

View 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

View 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]
}

View 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]
}

View 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]
}

View file

@ -0,0 +1,8 @@
import {Dispatch, SetStateAction} from "react"
export type ReturnTypeOfUseState<S> = [
S,
Dispatch<SetStateAction<S>>,
]

View file

@ -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}
}

View 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}
}

View file

@ -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]
}

View file

@ -13,9 +13,10 @@ export function StarredProvider({children}: {children: ReactNode}) {
if(!prev) { if(!prev) {
return [value] return [value]
} }
else { if(prev.indexOf(value) >= 0) {
return [...prev, value] return prev
} }
return [...prev, value]
}) })
}, []) }, [])
@ -39,7 +40,7 @@ export function StarredProvider({children}: {children: ReactNode}) {
} }
const result = [...prev] const result = [...prev]
const index = result.indexOf(value) const index = result.indexOf(value)
if(!index) { if(index <= -1) {
result.push(value) result.push(value)
} }
else { else {

View file

@ -1,20 +1,20 @@
"use client"; "use client";
import {useClientTranslation} from "@/app/(i18n)/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 {faGlobe} from "@fortawesome/free-solid-svg-icons"
import cn from "classnames" import cn from "classnames"
import {useRouter} from "next/navigation" 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" import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
export function CreatePublicBoardPanel({lang}: {lang: string}) { export function CreatePublicBoardPanel({lang}: {lang: string}) {
const {t} = useClientTranslation(lang, "root") const {t} = useClientTranslation(lang, "root")
const [code, setCode] = useLowerKebabState("") const [code, setCode] = useLowerKebabifier(useState<string | undefined>(undefined))
const router = useRouter(); const router = useRouter();
const codeIsValid = code.length >= 1 const codeIsValid = code && code.length >= 1
const createBoardValidated = useCallback((e: SyntheticEvent) => { const createBoardValidated = useCallback((e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
@ -48,7 +48,7 @@ export function CreatePublicBoardPanel({lang}: {lang: string}) {
<input <input
type={"text"} type={"text"}
placeholder={t("createPublicBoardCodePlaceholder")} placeholder={t("createPublicBoardCodePlaceholder")}
value={code} value={code ?? ""}
onChange={e => setCode(e.target.value)} onChange={e => setCode(e.target.value)}
/> />
<span/> <span/>

View file

@ -1,20 +1,20 @@
"use client"; "use client";
import {useClientTranslation} from "@/app/(i18n)/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 {faKey} from "@fortawesome/free-solid-svg-icons"
import cn from "classnames" import cn from "classnames"
import {useRouter} from "next/navigation" 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" import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"
export function KnownBoardsPanel({lang}: {lang: string}) { export function KnownBoardsPanel({lang}: {lang: string}) {
const {t} = useClientTranslation(lang, "root") const {t} = useClientTranslation(lang, "root")
const [code, setCode] = useLowerKebabState("") const [code, setCode] = useLowerKebabifier(useState<string | undefined>(undefined))
const router = useRouter(); const router = useRouter();
const codeIsValid = code.length >= 1 const codeIsValid = code && code.length >= 1
const moveToBoardValidated = useCallback((e: SyntheticEvent) => { const moveToBoardValidated = useCallback((e: SyntheticEvent) => {
e.preventDefault(); e.preventDefault();
@ -48,7 +48,7 @@ export function KnownBoardsPanel({lang}: {lang: string}) {
<input <input
type={"text"} type={"text"}
placeholder={t("existingKnownBoardsCodePlaceholder")} placeholder={t("existingKnownBoardsCodePlaceholder")}
value={code} value={code ?? ""}
onChange={e => setCode(e.target.value)} onChange={e => setCode(e.target.value)}
/> />
<span/> <span/>

View file

@ -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;

View file

@ -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
]
}

View file

@ -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
]
}

View file

@ -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,
]
}

View file

@ -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

View file

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

View file

@ -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"

View file

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

View file

@ -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
]
}

View file

@ -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;

View file

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

View file

@ -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,
]
}

View file

@ -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"

View file

@ -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},
}

View file

@ -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
}
}

View file

@ -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: {}}

View file

@ -0,0 +1 @@
export {useBoardState} from "./useBoardState"

View file

@ -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}
}

View 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,
}

View file

@ -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",
}

View file

@ -0,0 +1,4 @@
/**
* Unique **string** representing a task on a board.
*/
export type TaskId = string;

View file

@ -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",
}

View file

@ -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",
}

View file

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

View file

@ -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"

View file

@ -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,
}

View file

@ -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}
}

View file

@ -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
}

View file

@ -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);

View file

@ -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>
)
}

View file

@ -0,0 +1,2 @@
export {BoardProvider} from "./BoardProvider"
export {useBoardConsumer} from "./useBoardConsumer"

View file

@ -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
}

View file

@ -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);
}

View file

@ -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>
)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -5,18 +5,18 @@
grid-column-gap: 10px; grid-column-gap: 10px;
} }
@media screen and (max-width: 450px) { @media screen and (max-width: 768px) {
.boardHeader { .boardHeader {
grid-template-areas: grid-template-areas:
"left right" "left right"
"title title" "title title"
; ;
grid-template-columns: auto auto; grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto; grid-template-rows: auto auto;
} }
} }
@media screen and (min-width: 451px) { @media screen and (min-width: 769px) {
.boardHeader { .boardHeader {
grid-template-areas: grid-template-areas:
"left title right" "left title right"
@ -36,14 +36,9 @@
.titleArea { .titleArea {
grid-area: title; grid-area: title;
height: 80px;
} }
.titleDisplay { .titleDisplay {
padding-top: 0.125em;
padding-right: 0.75ex;
padding-left: 0.75ex;
padding-bottom: calc(0.125em + 2px);
} }
.titleInput { .titleInput {

View file

@ -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>
)
}

View file

@ -0,0 +1,9 @@
.boardHeaderTitleDisplay {
padding: 0 0.75ex 4px;
}
.boardHeaderTitle input {
padding: 0 0.75ex 2px;
width: 100%;
text-align: center;
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -0,0 +1,4 @@
export enum ColumningMode {
MultiColumn,
SingleColumn,
}

View file

@ -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,
}

View file

@ -0,0 +1,2 @@
export {ColumningMode} from "./ColumningMode"
export {COLUMNING_MODE_TO_ICON} from "./columningModeToIcon"

View file

@ -0,0 +1,6 @@
export enum GroupingMode {
ByImportance,
ByPriority,
ByStatus,
ByIcon,
}

View file

@ -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]
},
}

View file

@ -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
},
}

View file

@ -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]}/>
&nbsp;
{TaskIcon[k]}
</span>
)
},
}

View file

@ -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"

View file

@ -0,0 +1,7 @@
export enum SortingMode {
ByPriority,
ByImportance,
ByIcon,
ByText,
ByStatus,
}

View file

@ -0,0 +1 @@
export {SortingMode} from "./SortingMode"

View file

@ -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
}
}

View file

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

View file

@ -0,0 +1,4 @@
import {Task} from "@/app/[lang]/board/[board]/(api)/(task)"
export type TaskWithId = [string, Task]

View file

@ -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,
})
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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}
/>
}
}

View file

@ -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>
)
}

View file

@ -1,4 +1,4 @@
.boardLoading { .splashWithIcon {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
</>
}

View file

@ -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,
}
}

View file

@ -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}
}

View file

@ -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}
}

View file

@ -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,
}
}

View file

@ -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>
)
}

View file

@ -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}/>
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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
}

View file

@ -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>
)
}

View file

@ -1,14 +0,0 @@
.editorForm {
display: flex;
flex-direction: row;
}
.editorTextInput {
width: 100%;
height: 100%;
}
.editorSubmitButton {
width: 44px;
height: 44px;
}

View file

@ -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