diff --git a/.env b/.env index 1270639b3e1900504ba32fa1d636255f084e2901..fd4513532a5134d9e5040b2807ac5da2b32d69a9 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ REACT_APP_STYLEGUIDE_URL=http://localhost:3001 REACT_APP_API_BASE_URL=https://cf2021.pirati.cz/api -REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws/posts +REACT_APP_WS_BASE_URL=wss://cf2021.pirati.cz/ws diff --git a/src/App.jsx b/src/App.jsx index 88a4ccedf659dc3412738f83e78e2f940dd7c928..caecd16687bdbb4df84c5dcdad3aa49d5b1b3c37 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -27,11 +27,22 @@ const onKeycloakEvent = (event) => { if (["onAuthRefreshSuccess", "onAuthSuccess"].includes(event)) { Sentry.setUser(keycloak.tokenParsed); AuthStore.update((state) => { + const kcRoles = keycloak.tokenParsed.roles; + let role = null; + + if (kcRoles.includes("chairman")) { + role = "chairman"; + } else if (kcRoles.includes("member")) { + role = "member"; + } else { + role = "regp"; + } + state.isAuthenticated = true; state.user = { name: keycloak.tokenParsed.name, username: keycloak.tokenParsed.preferred_username, - groups: keycloak.tokenParsed.groups, + role, accessToken: keycloak.token, }; }); diff --git a/src/actions/announcements.js b/src/actions/announcements.js index 623725ffbd307ac254a0e117da6cb9b31214774d..9d43f8333d5aebf845313520e70ac1095f8c2616 100644 --- a/src/actions/announcements.js +++ b/src/actions/announcements.js @@ -1,36 +1,56 @@ -import findIndex from "lodash/findIndex"; -import remove from "lodash/remove"; -import { createAsyncAction, successResult } from "pullstate"; +import keyBy from "lodash/keyBy"; +import property from "lodash/property"; +import { createAsyncAction, errorResult, successResult } from "pullstate"; +import { fetch } from "api"; import { AnnouncementStore } from "stores"; +import { + announcementTypeMappingRev, + parseRawAnnouncement, + syncAnnoucementItemIds, +} from "utils"; -/** - * Add new announcement. - */ -export const addAnnouncement = createAsyncAction( - async ({ content }) => { - /** @type {CF2021.Announcement} */ - const payload = { - id: "999", - datetime: new Date(), - type: "announcement", - content, - seen: true, - }; - - return successResult(payload); +export const loadAnnouncements = createAsyncAction( + async () => { + try { + const resp = await fetch("/announcements"); + const data = await resp.json(); + return successResult(data.data); + } catch (err) { + return errorResult([], err.toString()); + } }, { postActionHook: ({ result }) => { if (!result.error) { + const announcements = result.payload.map(parseRawAnnouncement); + AnnouncementStore.update((state) => { - state.items.push(result.payload); + state.items = keyBy(announcements, property("id")); + syncAnnoucementItemIds(state); }); } }, } ); +/** + * Add new announcement. + */ +export const addAnnouncement = createAsyncAction(async ({ content }) => { + try { + const body = JSON.stringify({ + content, + type: announcementTypeMappingRev["announcement"], + }); + const resp = await fetch("/announcements", { method: "POST", body }); + const data = await resp.json(); + return successResult(data.data); + } catch (err) { + return errorResult([], err.toString()); + } +}); + /** * Delete existing announcement. */ @@ -40,16 +60,12 @@ export const deleteAnnouncement = createAsyncAction( * @param {CF2021.Announcement} item */ async (item) => { - return successResult(item); - }, - { - postActionHook: ({ result }) => { - if (!result.error) { - AnnouncementStore.update((state) => { - remove(state.items, { id: result.payload.id }); - }); - } - }, + try { + await fetch(`/announcements/${item.id}`, { method: "DELETE" }); + return successResult({ item }); + } catch (err) { + return errorResult([], err.toString()); + } } ); @@ -63,18 +79,14 @@ export const updateAnnouncementContent = createAsyncAction( * @param {string} newContent */ async ({ item, newContent }) => { - return successResult({ item, newContent }); - }, - { - postActionHook: ({ result }) => { - if (!result.error) { - AnnouncementStore.update((state) => { - const itemIdx = findIndex(state.items, { - id: result.payload.item.id, - }); - state.items[itemIdx].content = result.payload.newContent; - }); - } - }, + try { + const body = JSON.stringify({ + content: newContent, + }); + await fetch(`/announcements/${item.id}`, { method: "PUT", body }); + return successResult({ item, newContent }); + } catch (err) { + return errorResult([], err.toString()); + } } ); diff --git a/src/actions/misc.js b/src/actions/misc.js deleted file mode 100644 index ca57989d4093a06a4262e7fc3d3ca06c1231e9c1..0000000000000000000000000000000000000000 --- a/src/actions/misc.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createAsyncAction, errorResult, successResult } from "pullstate"; -import fetch from "unfetch"; - -import { AuthStore } from "stores"; - -export const loadGroupMappings = createAsyncAction( - async () => { - try { - const resp = await fetch("https://iapi.pirati.cz/v1/groups"); - const mappings = await resp.json(); - return successResult(mappings); - } catch (err) { - return errorResult([], err.toString()); - } - }, - { - postActionHook: ({ result }) => { - if (!result.error) { - AuthStore.update((state) => { - state.groupMappings = result.payload; - }); - } - }, - } -); - diff --git a/src/actions/posts.js b/src/actions/posts.js index 35c8d196d02d894041980adb26ad478f9293c8d7..24b1adfa20b4ff79fa009e462cb4124f81209304 100644 --- a/src/actions/posts.js +++ b/src/actions/posts.js @@ -4,7 +4,12 @@ import { createAsyncAction, errorResult, successResult } from "pullstate"; import { fetch } from "api"; import { PostStore } from "stores"; -import { filterPosts, parseRawPost, postsTypeMappingRev } from "utils"; +import { + filterPosts, + parseRawPost, + postsStateMappingRev, + postsTypeMappingRev, +} from "utils"; export const loadPosts = createAsyncAction( async () => { @@ -137,6 +142,19 @@ export const hide = createAsyncAction( } ); +/** + * + * @param {CF2021.ProposalPost} proposal + * @param {CF2021.ProposalPostState} state + */ +const updateProposalState = async (proposal, state) => { + const body = JSON.stringify({ + state: postsStateMappingRev[state], + }); + await fetch(`/posts/${proposal.id}`, { method: "PUT", body }); + return successResult(proposal); +}; + /** * Announce procedure proposal. */ @@ -144,8 +162,8 @@ export const announceProposal = createAsyncAction( /** * @param {CF2021.ProposalPost} proposal */ - async (proposal) => { - return successResult(proposal); + (proposal) => { + return updateProposalState(proposal, "announced"); }, { shortCircuitHook: ({ args }) => { @@ -159,13 +177,6 @@ export const announceProposal = createAsyncAction( return false; }, - postActionHook: ({ result }) => { - if (!result.error) { - PostStore.update((state) => { - state.items[result.payload.id].state = "announced"; - }); - } - }, } ); @@ -176,8 +187,8 @@ export const acceptProposal = createAsyncAction( /** * @param {CF2021.ProposalPost} proposal */ - async (proposal) => { - return successResult(proposal); + (proposal) => { + return updateProposalState(proposal, "accepted"); }, { shortCircuitHook: ({ args }) => { @@ -191,13 +202,6 @@ export const acceptProposal = createAsyncAction( return false; }, - postActionHook: ({ result }) => { - if (!result.error) { - PostStore.update((state) => { - state.items[result.payload.id].state = "accepted"; - }); - } - }, } ); @@ -208,8 +212,8 @@ export const rejectProposal = createAsyncAction( /** * @param {CF2021.ProposalPost} proposal */ - async (proposal) => { - return successResult(proposal); + (proposal) => { + return updateProposalState(proposal, "rejected"); }, { shortCircuitHook: ({ args }) => { @@ -223,12 +227,30 @@ export const rejectProposal = createAsyncAction( return false; }, - postActionHook: ({ result }) => { - if (!result.error) { - PostStore.update((state) => { - state.items[result.payload.id].state = "rejected"; - }); + } +); + +/** + * Reject procedure proposal. + */ +export const rejectProposalByChairman = createAsyncAction( + /** + * @param {CF2021.ProposalPost} proposal + */ + (proposal) => { + return updateProposalState(proposal, "rejected-by-chairman"); + }, + { + shortCircuitHook: ({ args }) => { + if (args.type !== "procedure-proposal") { + return errorResult(); } + + if (!["pending", "announced"].includes(args.state)) { + return errorResult(); + } + + return false; }, } ); diff --git a/src/actions/program.js b/src/actions/program.js index 23000ee52df40c78dfdb0c402e37132a3541e50e..be1612589476f7c3da615978fdd04e14acf9bd42 100644 --- a/src/actions/program.js +++ b/src/actions/program.js @@ -1,9 +1,13 @@ +import keyBy from "lodash/keyBy"; import pick from "lodash/pick"; +import property from "lodash/property"; import { createAsyncAction, errorResult, successResult } from "pullstate"; import { fetch } from "api"; import { ProgramStore } from "stores"; +import { loadPosts } from "./posts"; + export const loadProgram = createAsyncAction( async () => { try { @@ -33,6 +37,7 @@ export const loadProgram = createAsyncAction( "description", "proposer", ]), + discussionOpened: entry.discussion_opened, expectedStartAt: new Date(entry.expected_start_at), expectedFinishAt: entry.expected_finish_at ? new Date(entry.expected_finish_at) @@ -45,15 +50,11 @@ export const loadProgram = createAsyncAction( const currentEntry = result.payload.find((entry) => entry.is_live); ProgramStore.update((state) => { - state.schedule = entries; + state.items = keyBy(entries, property("id")); + state.scheduleIds = entries.map((entry) => entry.id); if (currentEntry) { - state.current = state.schedule.find( - (scheduleEntry) => scheduleEntry.id === currentEntry.id - ); - } else { - // TODO: for testing only - state.current = state.schedule[1]; + state.currentId = currentEntry.id; } }); } @@ -62,18 +63,92 @@ export const loadProgram = createAsyncAction( ); /** - * Open discussion. + * Rename program point. + */ +export const renameProgramPoint = createAsyncAction( + async ({ programEntry, newTitle }) => { + try { + const body = JSON.stringify({ + title: newTitle, + }); + await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); + return successResult({ programEntry, newTitle }); + } catch (err) { + return errorResult([], err.toString()); + } + }, + { + postActionHook: ({ result }) => { + if (!result.error) { + ProgramStore.update((state) => { + if (state.items[result.payload.programEntry.id]) { + state.items[result.payload.programEntry.id].title = + result.payload.newTitle; + } + }); + } + }, + } +); + +/** + * End program point. */ export const endProgramPoint = createAsyncAction( - async () => { - return successResult(); + /** + * + * @param {CF2021.ProgramScheduleEntry} programEntry + */ + async (programEntry) => { + try { + const body = JSON.stringify({ + is_live: false, + }); + await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); + return successResult(programEntry); + } catch (err) { + return errorResult([], err.toString()); + } }, { postActionHook: ({ result }) => { if (!result.error) { ProgramStore.update((state) => { - state.current = null; + state.currentId = null; + }); + } + }, + } +); + +/** + * Activate program point. + */ +export const activateProgramPoint = createAsyncAction( + /** + * + * @param {CF2021.ProgramScheduleEntry} programEntry + */ + async (programEntry) => { + try { + const body = JSON.stringify({ + is_live: true, + }); + await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); + return successResult(programEntry); + } catch (err) { + return errorResult([], err.toString()); + } + }, + { + postActionHook: async ({ result }) => { + if (!result.error) { + ProgramStore.update((state) => { + state.currentId = result.payload.id; }); + + // Re-load posts - these are bound directly to the program schedule entry. + loadPosts.run({}, { respectCache: false }); } }, } @@ -83,14 +158,28 @@ export const endProgramPoint = createAsyncAction( * Open discussion. */ export const openDiscussion = createAsyncAction( - async () => { - return successResult(); + /** + * + * @param {CF2021.ProgramScheduleEntry} programEntry + */ + async (programEntry) => { + try { + const body = JSON.stringify({ + discussion_opened: true, + }); + await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); + return successResult(programEntry); + } catch (err) { + return errorResult([], err.toString()); + } }, { postActionHook: ({ result }) => { if (!result.error) { ProgramStore.update((state) => { - state.current.discussionOpened = true; + if (state.items[result.payload.id]) { + state.items[result.payload.id].discussionOpened = true; + } }); } }, @@ -101,14 +190,24 @@ export const openDiscussion = createAsyncAction( * Close discussion. */ export const closeDiscussion = createAsyncAction( - async () => { - return successResult(); + async (programEntry) => { + try { + const body = JSON.stringify({ + discussion_opened: false, + }); + await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); + return successResult(programEntry); + } catch (err) { + return errorResult([], err.toString()); + } }, { postActionHook: ({ result }) => { if (!result.error) { ProgramStore.update((state) => { - state.current.discussionOpened = false; + if (state.items[result.payload.id]) { + state.items[result.payload.id].discussionOpened = false; + } }); } }, diff --git a/src/actions/ws.js b/src/actions/ws.js index 59b5c6e7f0cbf60a3ff1743cff82e314e31dfd4a..59150d3efe2c9ca2b46d0968aa5621a2c5f73f1a 100644 --- a/src/actions/ws.js +++ b/src/actions/ws.js @@ -2,6 +2,7 @@ import { createAsyncAction, errorResult, successResult } from "pullstate"; import { connect } from "ws/connection"; +import { loadAnnouncements } from "./announcements"; import { loadPosts } from "./posts"; import { loadProgram } from "./program"; @@ -13,6 +14,7 @@ export const initializeWSChannel = createAsyncAction(async () => { // any intermediate state. await Promise.all([ loadProgram.run({}, { respectCache: false }), + loadAnnouncements.run({}, { respectCache: false }), loadPosts.run({}, { respectCache: false }), ]); diff --git a/src/api.js b/src/api.js index 707c7c2af86436cb7fd27976829f1dc7d08f9f2e..c7d5efd34840b6f275fe913ab5c692ccf527a548 100644 --- a/src/api.js +++ b/src/api.js @@ -12,5 +12,9 @@ export const fetch = (url, opts) => { opts.headers.Authorization = "Bearer " + user.accessToken; } + if (!opts.headers["Content-Type"]) { + opts.headers["Content-Type"] = "application/json"; + } + return baseFetch(process.env.REACT_APP_API_BASE_URL + url, opts); }; diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 28d2d9f1e807c6629c133e19648fe71f9d95ed9f..e2ce840a7441f89e32d3c63a06efca87b04fa848 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -48,6 +48,11 @@ const Navbar = () => { <> <div className="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto"> <ul className="navbar-menu text-white"> + <li className="navbar-menu__item"> + <NavLink className="navbar-menu__link" to="/"> + Přímý přenos + </NavLink> + </li> <li className="navbar-menu__item"> <NavLink className="navbar-menu__link" to="/program"> Program diff --git a/src/components/Thumbs.jsx b/src/components/Thumbs.jsx index 6f23ccecdfaca755e6a4b1449a3b1ae5c6620f51..8f39694e3d187ffabb4cd235994eb7e893668f19 100644 --- a/src/components/Thumbs.jsx +++ b/src/components/Thumbs.jsx @@ -1,18 +1,27 @@ import React from "react"; +import classNames from "classnames"; -const Thumbs = ({ likes, dislikes, onLike, onDislike, myVote }) => { +const Thumbs = ({ likes, dislikes, onLike, onDislike, readOnly }) => { return ( <div> <div className="space-x-2 text-sm flex items-center"> <button - className="text-blue-300 flex items-center space-x-1 cursor-pointer" + className={classNames("text-blue-300 flex items-center space-x-1", { + "cursor-pointer": !readOnly, + "cursor-default": readOnly, + })} + disabled={readOnly} onClick={onLike} > <span className="font-bold">{likes}</span> <i className="ico--thumbs-up"></i> </button> <button - className="text-red-600 flex items-center space-x-1 cursor-pointer" + className={classNames("text-red-600 flex items-center space-x-1", { + "cursor-pointer": !readOnly, + "cursor-default": readOnly, + })} + disabled={readOnly} onClick={onDislike} > <i className="ico--thumbs-down transform -scale-x-1"></i> diff --git a/src/components/annoucements/Announcement.jsx b/src/components/annoucements/Announcement.jsx index 34a39025f6b3c9849d75c853894344770fe91ac0..2d6c72aa8dd70bdb662be43e8b44627c8309195e 100644 --- a/src/components/annoucements/Announcement.jsx +++ b/src/components/annoucements/Announcement.jsx @@ -14,7 +14,7 @@ const Announcement = ({ link, relatedPostId, seen, - displayActions = false, + canRunActions, onDelete, onEdit, onSeen, @@ -85,7 +85,7 @@ const Announcement = ({ </Chip> {link && <a href={link}>{linkLabel + "»"}</a>} </div> - {displayActions && ( + {canRunActions && ( <DropdownMenu right triggerIconClass="ico--dots-three-horizontal"> {showEdit && ( <DropdownMenuItem diff --git a/src/components/annoucements/AnnouncementEditModal.jsx b/src/components/annoucements/AnnouncementEditModal.jsx index 57d96813768d77200be32261db2cfd1115d0b8cb..df5d583f03c86f6f4c33377138355f9a5a36f281 100644 --- a/src/components/annoucements/AnnouncementEditModal.jsx +++ b/src/components/annoucements/AnnouncementEditModal.jsx @@ -33,6 +33,9 @@ const AnnouncementEditModal = ({ </button> </div> <div className="form-field"> + <label className="form-field__label" htmlFor="field"> + Nový text oznámení + </label> <div className="form-field__wrapper form-field__wrapper--shadowed"> <textarea className="text-input form-field__control text-base" diff --git a/src/components/annoucements/AnnouncementList.jsx b/src/components/annoucements/AnnouncementList.jsx index 693cf9d1e9258a55a8b6a4d57f7c306da2faee47..c275a6532437b82bcd0d13415e7b67c0589c2256 100644 --- a/src/components/annoucements/AnnouncementList.jsx +++ b/src/components/annoucements/AnnouncementList.jsx @@ -6,7 +6,7 @@ import Announcement from "./Announcement"; const AnnouncementList = ({ items, className, - displayActions, + canRunActions, onDelete, onEdit, onSeen, @@ -33,7 +33,7 @@ const AnnouncementList = ({ content={item.content} link={item.link} seen={item.seen} - displayActions={displayActions} + canRunActions={canRunActions} onEdit={onAnnouncementEdit(item)} onDelete={onAnnouncementDelete(item)} onSeen={onAnnouncementSeen(item)} diff --git a/src/components/posts/Post.jsx b/src/components/posts/Post.jsx index d78acdbb3f52903fd178b9e8113a624369369def..5836b184a3b2cbf72f54c1b0321180fc29b81e82 100644 --- a/src/components/posts/Post.jsx +++ b/src/components/posts/Post.jsx @@ -19,7 +19,8 @@ const Post = ({ archived, state, dimIfArchived = true, - displayActions = false, + canThumb, + canRunActions, onLike, onDislike, onHide, @@ -27,6 +28,7 @@ const Post = ({ onAnnounceProcedureProposal, onAcceptProcedureProposal, onRejectProcedureProposal, + onRejectProcedureProposalByChairman, onSeen, }) => { const { ref, inView } = useInView({ @@ -124,6 +126,8 @@ const Post = ({ type === "procedure-proposal" && state === "announced"; const showRejectAction = type === "procedure-proposal" && state === "announced"; + const showRejectByChairmanAction = + type === "procedure-proposal" && ["announced", "pending"].includes(state); const showBanAction = true; const showHideAction = !archived; @@ -160,11 +164,12 @@ const Post = ({ <Thumbs likes={ranking.likes} dislikes={ranking.dislikes} + readOnly={!canThumb} onLike={onLike} onDislike={onDislike} myVote={ranking.myVote} /> - {displayActions && ( + {canRunActions && ( <DropdownMenu right> {showAnnounceAction && ( <DropdownMenuItem @@ -187,6 +192,13 @@ const Post = ({ title="Zamítnout procedurální návrh" /> )} + {showRejectByChairmanAction && ( + <DropdownMenuItem + onClick={onRejectProcedureProposalByChairman} + icon="ico--thumbs-down" + title="Zamítnout procedurální návrh předsedajícím" + /> + )} {showBanAction && ( <DropdownMenuItem onClick={onBanUser} diff --git a/src/components/posts/PostList.jsx b/src/components/posts/PostList.jsx index 0984dfa7033921f64bb0584af48cf014275db85d..94e553541e0e473542874919867355e60a866a33 100644 --- a/src/components/posts/PostList.jsx +++ b/src/components/posts/PostList.jsx @@ -6,6 +6,8 @@ import Post from "./Post"; const PostList = ({ className, items, + canThumb, + canRunActions, onLike, onDislike, onHide, @@ -13,6 +15,7 @@ const PostList = ({ onAnnounceProcedureProposal, onAcceptProcedureProposal, onRejectProcedureProposal, + onRejectProcedureProposalByChairman, onSeen, dimArchived, }) => { @@ -30,6 +33,9 @@ const PostList = ({ ); const onPostAcceptProcedureProposal = buildHandler(onAcceptProcedureProposal); const onPostRejectProcedureProposal = buildHandler(onRejectProcedureProposal); + const onPostRejectProcedureProposalByChairman = buildHandler( + onRejectProcedureProposalByChairman + ); const onPostSeen = (post) => () => { onSeen(post); @@ -52,8 +58,9 @@ const PostList = ({ modified={item.modified} seen={item.seen} archived={item.archived} - displayActions={true} dimIfArchived={dimArchived} + canThumb={canThumb} + canRunActions={canRunActions} onLike={onPostLike(item)} onDislike={onPostDislike(item)} onHide={onPostHide(item)} @@ -61,6 +68,9 @@ const PostList = ({ onAnnounceProcedureProposal={onPostAnnounceProcedureProposal(item)} onAcceptProcedureProposal={onPostAcceptProcedureProposal(item)} onRejectProcedureProposal={onPostRejectProcedureProposal(item)} + onRejectProcedureProposalByChairman={onPostRejectProcedureProposalByChairman( + item + )} onSeen={onPostSeen(item)} /> ))} diff --git a/src/components/program/ProgramEntryEditModal.jsx b/src/components/program/ProgramEntryEditModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..90b5385514fa5d3424277ad6d8865334b52b64e3 --- /dev/null +++ b/src/components/program/ProgramEntryEditModal.jsx @@ -0,0 +1,73 @@ +import React, { useState } from "react"; + +import Button from "components/Button"; +import { Card, CardActions, CardBody, CardHeadline } from "components/cards"; +import Modal from "components/modals/Modal"; + +const ProgramEntryEditModal = ({ + programEntry, + onCancel, + onConfirm, + ...props +}) => { + const [text, setText] = useState(programEntry.title); + + const onTextInput = (evt) => { + setText(evt.target.value); + }; + + const confirm = (evt) => { + if (!!text) { + onConfirm(text); + } + }; + + return ( + <Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}> + <Card> + <CardBody> + <div className="flex items-center justify-between mb-4"> + <CardHeadline>Upravit název programového bodu</CardHeadline> + <button onClick={onCancel}> + <i className="ico--close"></i> + </button> + </div> + <div className="form-field"> + <label className="form-field__label" htmlFor="field"> + Nový název + </label> + <div className="form-field__wrapper form-field__wrapper--shadowed"> + <input + type="text" + className="text-input form-field__control" + value={text} + onChange={onTextInput} + placeholder="Vyplňte nový název" + /> + </div> + </div> + </CardBody> + <CardActions right className="space-x-1"> + <Button + hoverActive + color="blue-300" + className="text-sm" + onClick={confirm} + > + Uložit + </Button> + <Button + hoverActive + color="red-600" + className="text-sm" + onClick={onCancel} + > + Zrušit + </Button> + </CardActions> + </Card> + </Modal> + ); +}; + +export default ProgramEntryEditModal; diff --git a/src/containers/AnnoucementsContainer.jsx b/src/containers/AnnoucementsContainer.jsx index 4cd97890d549e6c93191fb8bd4999f96ea16c385..1a2318ab73115b668edaf51f09f006a251696dd4 100644 --- a/src/containers/AnnoucementsContainer.jsx +++ b/src/containers/AnnoucementsContainer.jsx @@ -8,8 +8,7 @@ import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal import AnnouncementList from "components/annoucements/AnnouncementList"; import ModalConfirm from "components/modals/ModalConfirm"; import { useItemActionConfirm } from "hooks"; -import { AnnouncementStore } from "stores"; -import findIndex from "lodash/findIndex"; +import { AnnouncementStore, AuthStore } from "stores"; const AnnoucementsContainer = () => { const [itemToEdit, setItemToEdit] = useState(null); @@ -21,7 +20,10 @@ const AnnoucementsContainer = () => { onDeleteCancel, ] = useItemActionConfirm(deleteAnnouncement); - const items = AnnouncementStore.useState((state) => state.items); + const { isAuthenticated, user } = AuthStore.useState(); + const items = AnnouncementStore.useState((state) => + state.itemIds.map((id) => state.items[id]) + ); const confirmEdit = useCallback( async (newContent) => { @@ -38,13 +40,12 @@ const AnnoucementsContainer = () => { }, [setItemToEdit]); /** - * Mark down user saw this post already. - * @param {CF2021.Announcement} post + * Mark down user saw this announcement already. + * @param {CF2021.Announcement} announcement */ const markSeen = (announcement) => { AnnouncementStore.update((state) => { - const idx = findIndex(state.items, announcement); - state.items[idx].seen = true; + state.items[announcement.id].seen = true; }); }; @@ -52,7 +53,7 @@ const AnnoucementsContainer = () => { <> <AnnouncementList items={items} - displayActions={true} + canRunActions={isAuthenticated && user.role === "chairman"} onDelete={setItemToDelete} onEdit={setItemToEdit} onSeen={markSeen} @@ -61,10 +62,10 @@ const AnnoucementsContainer = () => { isOpen={!!itemToDelete} onConfirm={onDeleteConfirm} onCancel={onDeleteCancel} - title="Opravdu chcete toto oznámení smazat?" + title="Opravdu smazat?" yesActionLabel="Smazat" > - Opravdu chcete ukončit rozpravu? + Tato akce je nevratná. Opravdu chcete toto oznámení smazat? </ModalConfirm> {itemToEdit && ( <AnnouncementEditModal diff --git a/src/containers/PostsContainer.jsx b/src/containers/PostsContainer.jsx index 5f7a01a17f2670f933d07e42d9d22213a3ebb637..c0ebf21a5163c680a09ea69172b2f696fd73172b 100644 --- a/src/containers/PostsContainer.jsx +++ b/src/containers/PostsContainer.jsx @@ -8,12 +8,13 @@ import { hide, like, rejectProposal, + rejectProposalByChairman, } from "actions/posts"; import { ban } from "actions/users"; import ModalConfirm from "components/modals/ModalConfirm"; import PostList from "components/posts/PostList"; import { useItemActionConfirm } from "hooks"; -import { PostStore } from "stores"; +import { AuthStore, PostStore } from "stores"; const PostsContainer = ({ className }) => { const [ @@ -46,7 +47,14 @@ const PostsContainer = ({ className }) => { onRejectConfirm, onRejectCancel, ] = useItemActionConfirm(rejectProposal); + const [ + postToRejectByChairman, + setPostToRejectByChairman, + onRejectByChairmanConfirm, + onRejectByChairmanCancel, + ] = useItemActionConfirm(rejectProposalByChairman); + const { isAuthenticated, user } = AuthStore.useState(); const { window, items } = PostStore.useState((state) => pick(state, ["window", "items"]) ); @@ -81,17 +89,19 @@ const PostsContainer = ({ className }) => { items={window.items .slice(sliceStart, sliceEnd) .map((postId) => items[postId])} + canThumb={isAuthenticated} + canRunActions={isAuthenticated && user.role === "chairman"} onLike={like.run} onDislike={dislike.run} onSeen={markSeen} className={className} dimArchived={!showingArchivedOnly} - displayActions={true} onHide={setPostToHide} onBanUser={onBanUser} onAnnounceProcedureProposal={setPostToAnnounce} onAcceptProcedureProposal={setPostToAccept} onRejectProcedureProposal={setPostToReject} + onRejectProcedureProposalByChairman={setPostToRejectByChairman} /> <ModalConfirm isOpen={!!userToBan} @@ -148,6 +158,16 @@ const PostsContainer = ({ className }) => { > Procedurální návrh bude <strong>zamítnut</strong>. Opravdu to chcete? </ModalConfirm> + <ModalConfirm + isOpen={!!postToRejectByChairman} + onConfirm={onRejectByChairmanConfirm} + onCancel={onRejectByChairmanCancel} + title="Zamítnout procedurální návrh předsedajícícm?" + yesActionLabel="Zamítnout návrh předsedajícím" + > + Procedurální návrh bude <strong>zamítnut předsedajícím</strong>. Opravdu + to chcete? + </ModalConfirm> </> ); }; diff --git a/src/hooks.js b/src/hooks.js index f138fe47126663faeb7d5cb36f5902fb8459307c..31703eb9b35e5513dd24b602ee8445ef8605a729 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -17,15 +17,15 @@ export const useItemActionConfirm = (actionFn) => { return [item, setItem, onActionConfirm, onActionCancel]; }; -export const useActionConfirm = (actionFn) => { +export const useActionConfirm = (actionFn, actionArgs) => { const [showConfirm, setShowConfirm] = useState(false); const onActionConfirm = useCallback(() => { if (showConfirm) { - actionFn.run(); + actionFn.run(actionArgs); setShowConfirm(false); } - }, [showConfirm, setShowConfirm, actionFn]); + }, [showConfirm, setShowConfirm, actionFn, actionArgs]); const onActionCancel = useCallback(() => { setShowConfirm(false); diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 4b0c764ac12d6a757aee3fb72151feb4a8942817..0e7c7a438559961cba97aa5060f808966db690bf 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,22 +1,24 @@ -import React from "react"; +import React, { useState } from "react"; import { closeDiscussion, endProgramPoint, openDiscussion, + renameProgramPoint, } from "actions/program"; import Button from "components/Button"; import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu"; import ModalConfirm from "components/modals/ModalConfirm"; +import ProgramEntryEditModal from "components/program/ProgramEntryEditModal"; import AddAnnouncementForm from "containers/AddAnnouncementForm"; import AddPostForm from "containers/AddPostForm"; import AnnouncementsContainer from "containers/AnnoucementsContainer"; import PostFilters from "containers/PostFilters"; import PostsContainer from "containers/PostsContainer"; import { useActionConfirm } from "hooks"; -import { ProgramStore } from "stores"; +import { AuthStore, ProgramStore } from "stores"; -const noCurrentDiscussion = ( +const noprogramEntryDiscussion = ( <article className="container container--wide pt-8 py-8 lg:py-32"> <div className="hidden md:inline-block flag bg-violet-400 text-white head-alt-base mb-4 py-4 px-5"> Jejda ... @@ -35,18 +37,22 @@ const noCurrentDiscussion = ( ); const Home = () => { + const { currentId, items } = ProgramStore.useState(); + const { isAuthenticated, user } = AuthStore.useState(); + const programEntry = currentId ? items[currentId] : null; + const [showProgramEditModal, setShowProgramEditModal] = useState(false); const [ showCloseDiscussion, setShowCloseDiscussion, onCloseDiscussionConfirm, onCloseDiscussionCancel, - ] = useActionConfirm(closeDiscussion); + ] = useActionConfirm(closeDiscussion, programEntry); const [ showOpenDiscussion, setShowOpenDiscussion, onOpenDiscussionConfirm, onOpenDiscussionCancel, - ] = useActionConfirm(openDiscussion); + ] = useActionConfirm(openDiscussion, programEntry); const [ showEndProgramPoint, setShowEndProgramPoint, @@ -54,14 +60,16 @@ const Home = () => { onEndProgramPointCancel, ] = useActionConfirm(endProgramPoint); - const { current } = ProgramStore.useState(); - - const onRenameProgramPoint = () => { - console.log("renameProgramPoint"); + const onEditProgramConfirm = async (newTitle) => { + await renameProgramPoint.run({ programEntry, newTitle }); + setShowProgramEditModal(false); + }; + const onEditProgramCancel = () => { + setShowProgramEditModal(false); }; - if (!current) { - return noCurrentDiscussion; + if (!programEntry) { + return noprogramEntryDiscussion; } return ( @@ -70,17 +78,17 @@ const Home = () => { <section className="cf2021__video space-y-8"> <div className="flex items-center justify-between mb-4 lg:mb-8"> <h1 className="head-alt-md lg:head-alt-lg mb-0"> - Bod č. {current.number}: {current.title} + Bod č. {programEntry.number}: {programEntry.title} </h1> <DropdownMenu right triggerSize="lg"> <DropdownMenuItem - onClick={onRenameProgramPoint} + onClick={() => setShowProgramEditModal(true)} icon="ico--edit-pencil" title="Přejmenovat bod programu" titleSize="base" iconSize="base" /> - {current.discussionOpened && ( + {programEntry.discussionOpened && ( <DropdownMenuItem onClick={() => setShowCloseDiscussion(true)} icon="ico--bubbles" @@ -89,7 +97,7 @@ const Home = () => { iconSize="base" /> )} - {!current.discussionOpened && ( + {!programEntry.discussionOpened && ( <DropdownMenuItem onClick={() => setShowOpenDiscussion(true)} icon="ico--bubbles" @@ -127,7 +135,9 @@ const Home = () => { </div> <AnnouncementsContainer className="container-padding--zero lg:container-padding--auto" /> - <AddAnnouncementForm className="lg:card__body pt-4 lg:py-6" /> + {isAuthenticated && user.role === "chairman" && ( + <AddAnnouncementForm className="lg:card__body pt-4 lg:py-6" /> + )} </div> </section> @@ -140,11 +150,17 @@ const Home = () => { </div> <PostsContainer className="container-padding--zero lg:container-padding--auto" /> - {current.discussionOpened && ( + {programEntry.discussionOpened && isAuthenticated && ( <AddPostForm className="my-8 space-y-4" /> )} </section> </article> + <ProgramEntryEditModal + isOpen={showProgramEditModal} + onConfirm={onEditProgramConfirm} + onCancel={onEditProgramCancel} + programEntry={programEntry} + /> <ModalConfirm isOpen={showCloseDiscussion} onConfirm={onCloseDiscussionConfirm} diff --git a/src/pages/Program.jsx b/src/pages/Program.jsx index bf31cf18ac699a07fef0e349f2041df32f1329b8..93ab83c9edd5854bbda18a54118f4de7d3e0ead8 100644 --- a/src/pages/Program.jsx +++ b/src/pages/Program.jsx @@ -3,17 +3,30 @@ import { Link } from "react-router-dom"; import classNames from "classnames"; import { format } from "date-fns"; +import { activateProgramPoint } from "actions/program"; +import Button from "components/Button"; import Chip from "components/Chip"; -import { ProgramStore } from "stores"; +import ModalConfirm from "components/modals/ModalConfirm"; +import { useItemActionConfirm } from "hooks"; +import { AuthStore, ProgramStore } from "stores"; const Schedule = () => { - const { current, schedule } = ProgramStore.useState(); + const { isAuthenticated, user } = AuthStore.useState(); + const { currentId, scheduleIds, items } = ProgramStore.useState(); + const [ + entryToActivate, + setEntryToActivate, + onActivateConfirm, + onActivateCancel, + ] = useItemActionConfirm(activateProgramPoint); + return ( <article className="container container--wide py-8 lg:py-24"> <h1 className="head-alt-md lg:head-alt-lg mb-8">Program zasedání</h1> <div className="flex flex-col"> - {schedule.map((entry) => { - const isCurrent = entry === current; + {scheduleIds.map((id) => { + const isCurrent = id === currentId; + const entry = items[id]; return ( <div className={classNames( @@ -44,11 +57,35 @@ const Schedule = () => { <span>{entry.proposer}</span> </div> {entry.description && <p>{entry.description}</p>} + {isAuthenticated && + user.role === "chairman" && + entry.id !== currentId && ( + <div className="mt-4"> + <Button + onClick={() => setEntryToActivate(entry)} + color="grey-125" + className="text-xs" + > + Aktivovat tento bod programu + </Button> + </div> + )} </div> </div> ); })} </div> + <ModalConfirm + isOpen={!!entryToActivate} + onConfirm={onActivateConfirm} + onCancel={onActivateCancel} + title="Aktivovat bod programu?" + yesActionLabel="Aktivovat" + > + Pogramovaný bod{" "} + <strong>{entryToActivate && entryToActivate.title}</strong> bude + aktivován. Chcete pokračovat? + </ModalConfirm> </article> ); }; diff --git a/src/stores.js b/src/stores.js index 95586eff6d37b3311416bbf06c0572bbc01171c9..aff0f1bddee9ac1ce96dfc89b9699f5879f38b70 100644 --- a/src/stores.js +++ b/src/stores.js @@ -4,76 +4,27 @@ import { Store } from "pullstate"; /** @type {CF2021.AuthStorePayload} */ const authStoreInitial = { isAuthenticated: false, - groupMappings: [], }; export const AuthStore = new Store(authStoreInitial); /** @type {CF2021.ProgramStorePayload} */ const programStoreInitial = { - current: undefined, - schedule: [], + items: {}, + currentId: undefined, + scheduleIds: [], }; export const ProgramStore = new Store(programStoreInitial); /** @type {CF2021.AnnouncementStorePayload} */ const announcementStoreInitial = { - items: [ - { - id: "1", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: false, - type: "rejected-procedure-proposal", - }, - { - id: "2", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: false, - type: "accepted-procedure-proposal", - }, - { - id: "3", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: true, - type: "suggested-procedure-proposal", - }, - { - id: "4", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: true, - type: "voting", - }, - { - id: "5", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: true, - type: "announcement", - }, - { - id: "6", - content: - "Shizz fo shizzle mah nizzle fo rizzle, mah home g-dizzle, gravida vizzle, arcu. Pellentesque crunk tortizzle. Sed erizzle. Black izzle sheezy telliv.", - datetime: new Date(), - seen: true, - type: "user-ban", - }, - ], + items: {}, + itemIds: [], }; export const AnnouncementStore = new Store(announcementStoreInitial); - /** @type {CF2021.PostStorePayload} */ const postStoreInitial = { items: {}, diff --git a/src/utils.js b/src/utils.js index 7aab9151b7930304d56054c65e910379f63322c2..65d16be18e5e56c2511ad9ef4788049dafa1c77c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ import filter from "lodash/filter"; +import pick from "lodash/pick"; import property from "lodash/property"; import values from "lodash/values"; -import pick from "lodash/pick"; /** * Filter & sort collection of posts. @@ -45,6 +45,16 @@ export const updateWindowPosts = (state) => { ); }; +/** + * Update itemIds from items. + * @param {CF2021.AnnouncementStorePayload} state + */ +export const syncAnnoucementItemIds = (state) => { + state.itemIds = values(state.items) + .sort((a, b) => b.datetime - a.datetime) + .map((announcement) => announcement.id); +}; + export const postsMyVoteMapping = { 0: "none", 1: "like", @@ -69,6 +79,32 @@ export const postsStateMapping = { 4: "rejected-by-chairman", }; +export const postsStateMappingRev = { + pending: 0, + announced: 1, + accepted: 2, + rejected: 3, + "rejected-by-chairman": 4, +}; + +export const announcementTypeMapping = { + 0: "rejected-procedure-proposal", + 1: "accepted-procedure-proposal", + 2: "suggested-procedure-proposal", + 3: "voting", + 4: "announcement", + 5: "user-ban", +}; + +export const announcementTypeMappingRev = { + "rejected-procedure-proposal": 0, + "accepted-procedure-proposal": 1, + "suggested-procedure-proposal": 2, + voting: 3, + announcement: 4, + "user-ban": 5, +}; + /** * Parse single post from the API. * @@ -94,8 +130,25 @@ export const parseRawPost = (rawPost) => { }; if (post.type === "procedure-proposal") { - post.state = postsStateMapping[post.state]; + post.state = postsStateMapping[rawPost.state]; } return post; }; + +/** + * Parse single announcement from the API. + * + * @param {any} rawAnnouncement + * @returns {CF2021.Announcement} + */ +export const parseRawAnnouncement = (rawAnnouncement) => { + const announcement = { + ...pick(rawAnnouncement, ["id", "content", "link"]), + datetime: new Date(rawAnnouncement.datetime), + type: announcementTypeMapping[rawAnnouncement.type], + seen: false, + }; + + return announcement; +}; diff --git a/src/ws/connection.js b/src/ws/connection.js index ab2a5f1d863799b3e79663e0b49e4f2d111157d7..df922b2d76c43d52aa8720e199e9b32cf9328388 100644 --- a/src/ws/connection.js +++ b/src/ws/connection.js @@ -1,10 +1,6 @@ import WaitQueue from "wait-queue"; -import { handleRanking } from "./handlers"; - -const handlerMap = { - ranked: handleRanking, -}; +import { handlers } from "./handlers"; function Worker() { const queue = new WaitQueue(); @@ -25,7 +21,7 @@ function Worker() { return console.error("[ws][worker] Missing `event` field"); } - const handlerFn = handlerMap[data.event]; + const handlerFn = handlers[data.event]; if (!handlerFn) { return console.warn(`[ws][worker] Can't handle event '${data.event}'`); @@ -33,7 +29,7 @@ function Worker() { handlerFn(data.payload || {}); } catch (err) { - console.error("[ws][worker] Could not parse message as JSON."); + console.error("[ws][worker] Could not parse message.", err); } }; @@ -80,7 +76,7 @@ export const connect = ({ onConnect }) => { ); clearInterval(keepAliveInterval); - setTimeout(connect, 1000); + setTimeout(() => connect({ onConnect }), 1000); }; ws.onerror = (err) => { diff --git a/src/ws/handlers/announcements.js b/src/ws/handlers/announcements.js new file mode 100644 index 0000000000000000000000000000000000000000..b49673f88b0656afcff79f1bae3c75d77d7efc4c --- /dev/null +++ b/src/ws/handlers/announcements.js @@ -0,0 +1,28 @@ +import has from "lodash/has"; + +import { AnnouncementStore } from "stores"; +import { parseRawAnnouncement, syncAnnoucementItemIds } from "utils"; + +export const handleAnnouncementChanged = (payload) => { + AnnouncementStore.update((state) => { + if (state.items[payload.id]) { + if (has(payload, "content")) { + state.items[payload.id].content = payload.content; + } + } + }); +}; + +export const handleAnnouncementCreated = (payload) => { + AnnouncementStore.update((state) => { + state.items[payload.id] = parseRawAnnouncement(payload); + syncAnnoucementItemIds(state); + }); +}; + +export const handleAnnouncementDeleted = (payload) => { + AnnouncementStore.update((state) => { + delete state.items[payload.id]; + syncAnnoucementItemIds(state); + }); +}; diff --git a/src/ws/handlers/index.js b/src/ws/handlers/index.js index 4f89127aa97d187ec2f60a7c9aff2a1d7b80606e..b10730e64060537264959ca6a0f6950d79ec896f 100644 --- a/src/ws/handlers/index.js +++ b/src/ws/handlers/index.js @@ -1 +1,21 @@ -export * from "./posts"; +import { + handleAnnouncementChanged, + handleAnnouncementCreated, + handleAnnouncementDeleted, +} from "./announcements"; +import { + handlePostChanged, + handlePostCreated, + handlePostRanked, +} from "./posts"; +import { handleProgramEntryChanged } from "./program"; + +export const handlers = { + announcement_changed: handleAnnouncementChanged, + announcement_created: handleAnnouncementCreated, + announcement_deleted: handleAnnouncementDeleted, + post_ranked: handlePostRanked, + post_changed: handlePostChanged, + post_created: handlePostCreated, + program_entry_changed: handleProgramEntryChanged, +}; diff --git a/src/ws/handlers/posts.js b/src/ws/handlers/posts.js index 7ef6c3fc5a16d873e5d0e17bb7f10216313f4f4d..a277ed8a0843072a0407132aa444614dbf49df97 100644 --- a/src/ws/handlers/posts.js +++ b/src/ws/handlers/posts.js @@ -1,7 +1,9 @@ +import has from "lodash/has"; + import { PostStore } from "stores"; -import { parseRawPost, updateWindowPosts } from "utils"; +import { parseRawPost, postsStateMapping, updateWindowPosts } from "utils"; -export const handleRanking = (payload) => { +export const handlePostRanked = (payload) => { PostStore.update((state) => { if (state.items[payload.id]) { state.items[payload.id].ranking.likes = payload["ranking_likes"]; @@ -17,20 +19,25 @@ export const handleRanking = (payload) => { }); }; -export const handleChanged = (payload) => { +export const handlePostChanged = (payload) => { PostStore.update((state) => { if (state.items[payload.id]) { - state.items[payload.id].content = payload.content; - state.items[payload.id].modified = true; + if (has(payload, "content")) { + state.items[payload.id].content = payload.content; + state.items[payload.id].modified = true; + } + + if (has(payload, "state")) { + state.items[payload.id].state = postsStateMapping[payload.state]; + } } }); }; -export const handleCreated = (payload) => { +export const handlePostCreated = (payload) => { PostStore.update((state) => { - if (state.items[payload.id]) { - state.items[payload.id] = parseRawPost(payload); - updateWindowPosts(state); - } + state.items[payload.id] = parseRawPost(payload); + state.itemCount = Object.keys(state.items).length; + updateWindowPosts(state); }); }; diff --git a/src/ws/handlers/program.js b/src/ws/handlers/program.js new file mode 100644 index 0000000000000000000000000000000000000000..c677db120f93aa7e31e7c307bca8fe852dc0d76f --- /dev/null +++ b/src/ws/handlers/program.js @@ -0,0 +1,16 @@ +import has from "lodash/has"; + +import { ProgramStore } from "stores"; + +export const handleProgramEntryChanged = (payload) => { + ProgramStore.update((state) => { + if (state.items[payload.id]) { + if (has(payload, "discussion_opened")) { + state.items[payload.id].discussionOpened = payload.discussion_opened; + } + if (has(payload, "title")) { + state.items[payload.id].title = payload.title; + } + } + }); +}; diff --git a/typings/cf2021.d.ts b/typings/cf2021.d.ts index 048e486fc291baaa63409551f185a83952b49a85..63f15a564d6993b5dc6d2502a610016b285d4d7f 100644 --- a/typings/cf2021.d.ts +++ b/typings/cf2021.d.ts @@ -1,134 +1,140 @@ declare namespace CF2021 { - interface ProgramScheduleEntry { - id: number; - number: string; - title: string; - proposer: string; - description?: string; - expectedStartAt: Date; - expectedFinishAt?: Date; - } - - export interface ProgramStorePayload { - current?: ProgramScheduleEntry & { - discussionOpened: boolean; - } - schedule: ProgramScheduleEntry[]; - } - - interface GroupMapping { - id: number; - code: string; - name: string; - } - - export interface AnonymousAuthStorePayload { - isAuthenticated: false; - groupMappings: GroupMapping[]; - } - - export interface UserAuthStorePayload extends AnonymousAuthStorePayload { - isAuthenticated: true; - user: { - name: string; - username: string; - groups: string[]; - accessToken: string; - }; - } - - export type AuthStorePayload = - | AnonymousAuthStorePayload - | UserAuthStorePayload; - - export type AnnouncementType = - | "rejected-procedure-proposal" - | "accepted-procedure-proposal" - | "suggested-procedure-proposal" - | "voting" - | "announcement" - | "user-ban"; - - export interface Announcement { - id: string; - datetime: Date; - type: AnnouncementType; - content: string; - link?: string; - relatedPostId: string; - seen: boolean; - } - - export interface AnnouncementStorePayload { - items: Announcement[]; - } - - export type PostType = "post" | "procedure-proposal"; - - export interface AbstractPost { - id: number; - datetime: Date; - author: { - id: number; - name: string; - username: string; - group: string; - }; - type: PostType; - content: string; - ranking: { - score: number; - likes: number; - dislikes: number; - myVote: "like" | "dislike" | "none"; - }; - historyLog: { - attribute: string; - newValue: string; - datetime: Date; - originator: "self" | "chairman"; - }[]; - modified: boolean; - archived: boolean; - hidden: boolean; - seen: boolean; - } - - export interface DiscussionPost extends AbstractPost { - type: "post"; - } - - export interface ProposalPost extends AbstractPost { - type: "procedure-proposal"; - state: - | "pending" - | "announced" - | "accepted" - | "rejected" - | "rejected-by-chairman"; - } - - export type Post = ProposalPost | DiscussionPost; - - export interface PostStoreItems { - [key: string]: Post; - } - - export interface PostStoreFilters { - flags: "all" | "active" | "archived"; - sort: "byDate" | "byScore"; - type: "all" | "proposalsOnly" | "discussionOnly"; - } - - export interface PostStorePayload { - items: PostStoreItems; - itemCount: number; - window: { - items: string[]; - itemCount: number; - page: number; - perPage: number; - }; - filters: PostStoreFilters; - } + interface ProgramScheduleEntry { + id: number; + number: string; + title: string; + proposer: string; + discussionOpened: boolean; + description?: string; + expectedStartAt: Date; + expectedFinishAt?: Date; + } + + export interface ProgramStorePayload { + items: { + [key: number]: ProgramScheduleEntry; + }; + currentId?: number; + scheduleIds: number[]; + } + + interface GroupMapping { + id: number; + code: string; + name: string; + } + + export interface AnonymousAuthStorePayload { + isAuthenticated: false; + } + + export interface UserAuthStorePayload extends AnonymousAuthStorePayload { + isAuthenticated: true; + user: { + name: string; + username: string; + role: "regp" | "member" | "chairman"; + accessToken: string; + }; + } + + export type AuthStorePayload = + | AnonymousAuthStorePayload + | UserAuthStorePayload; + + export type AnnouncementType = + | "rejected-procedure-proposal" + | "accepted-procedure-proposal" + | "suggested-procedure-proposal" + | "voting" + | "announcement" + | "user-ban"; + + export interface Announcement { + id: number; + datetime: Date; + type: AnnouncementType; + content: string; + link?: string; + relatedPostId: string; + seen: boolean; + } + + export interface AnnouncementStorePayload { + items: { + [key: number]: Announcement; + }; + itemIds: number[]; + } + + export type PostType = "post" | "procedure-proposal"; + + export interface AbstractPost { + id: number; + datetime: Date; + author: { + id: number; + name: string; + username: string; + group: string; + }; + type: PostType; + content: string; + ranking: { + score: number; + likes: number; + dislikes: number; + myVote: "like" | "dislike" | "none"; + }; + historyLog: { + attribute: string; + newValue: string; + datetime: Date; + originator: "self" | "chairman"; + }[]; + modified: boolean; + archived: boolean; + hidden: boolean; + seen: boolean; + } + + export interface DiscussionPost extends AbstractPost { + type: "post"; + } + + export type ProposalPostState = + | "pending" + | "announced" + | "accepted" + | "rejected" + | "rejected-by-chairman"; + + export interface ProposalPost extends AbstractPost { + type: "procedure-proposal"; + state: ProposalPostState; + } + + export type Post = ProposalPost | DiscussionPost; + + export interface PostStoreItems { + [key: string]: Post; + } + + export interface PostStoreFilters { + flags: "all" | "active" | "archived"; + sort: "byDate" | "byScore"; + type: "all" | "proposalsOnly" | "discussionOnly"; + } + + export interface PostStorePayload { + items: PostStoreItems; + itemCount: number; + window: { + items: string[]; + itemCount: number; + page: number; + perPage: number; + }; + filters: PostStoreFilters; + } }