Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • to/cf-online-ui
  • vpfafrin/cf2021
2 results
Show changes
Showing
with 565 additions and 292 deletions
......@@ -4,7 +4,8 @@ import pick from "lodash/pick";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { ProgramStore } from "stores";
import { loadPosts } from "./posts";
......@@ -12,7 +13,7 @@ import { loadPosts } from "./posts";
export const loadProgram = createAsyncAction(
async () => {
try {
const resp = await fetch("/program");
const resp = await fetchApi("/program");
const mappings = await resp.json();
return successResult(mappings);
} catch (err) {
......@@ -37,22 +38,28 @@ export const loadProgram = createAsyncAction(
"title",
"description",
"proposer",
"speakers",
]),
fullTitle:
entry.number !== ""
? `${entry.number}. ${entry.title}`
: entry.title,
htmlContent: markdownConverter.makeHtml(entry.description),
discussionOpened: entry.discussion_opened,
expectedStartAt: parse(
entry.expected_start_at,
"yyyy-MM-dd HH:mm:ss",
new Date()
new Date(),
),
expectedFinishAt: entry.expected_finish_at
? parse(
entry.expected_finish_at,
"yyyy-MM-dd HH:mm:ss",
new Date()
new Date(),
)
: undefined,
};
}
},
)
.sort((a, b) => a.expectedStartAt - b.expectedStartAt);
......@@ -68,7 +75,7 @@ export const loadProgram = createAsyncAction(
});
}
},
}
},
);
/**
......@@ -80,7 +87,7 @@ export const renameProgramPoint = createAsyncAction(
const body = JSON.stringify({
title: newTitle,
});
await fetch(`/program/${programEntry.id}`, {
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -101,7 +108,7 @@ export const renameProgramPoint = createAsyncAction(
});
}
},
}
},
);
/**
......@@ -117,7 +124,7 @@ export const endProgramPoint = createAsyncAction(
const body = JSON.stringify({
is_live: false,
});
await fetch(`/program/${programEntry.id}`, {
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -135,7 +142,7 @@ export const endProgramPoint = createAsyncAction(
});
}
},
}
},
);
/**
......@@ -151,7 +158,7 @@ export const activateProgramPoint = createAsyncAction(
const body = JSON.stringify({
is_live: true,
});
await fetch(`/program/${programEntry.id}`, {
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -172,7 +179,7 @@ export const activateProgramPoint = createAsyncAction(
loadPosts.run({}, { respectCache: false });
}
},
}
},
);
/**
......@@ -188,7 +195,7 @@ export const openDiscussion = createAsyncAction(
const body = JSON.stringify({
discussion_opened: true,
});
await fetch(`/program/${programEntry.id}`, {
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -208,7 +215,7 @@ export const openDiscussion = createAsyncAction(
});
}
},
}
},
);
/**
......@@ -220,7 +227,7 @@ export const closeDiscussion = createAsyncAction(
const body = JSON.stringify({
discussion_opened: false,
});
await fetch(`/program/${programEntry.id}`, {
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -240,5 +247,5 @@ export const closeDiscussion = createAsyncAction(
});
}
},
}
},
);
import * as Sentry from "@sentry/react";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { AuthStore } from "stores";
import { fetchApi } from "api";
import keycloak from "keycloak";
import { AuthStore, PostStore } from "stores";
import { updateWindowPosts } from "utils";
export const loadMe = createAsyncAction(
/**
* @param {number} userId
*/
async (user) => {
async () => {
try {
const response = await fetch(`/users/me`, {
const response = await fetchApi(`/users/me`, {
method: "GET",
expectedStatus: 200,
});
......@@ -32,7 +35,7 @@ export const loadMe = createAsyncAction(
});
}
},
}
},
);
export const ban = createAsyncAction(
......@@ -41,7 +44,7 @@ export const ban = createAsyncAction(
*/
async (user) => {
try {
await fetch(`/users/${user.id}/ban`, {
await fetchApi(`/users/${user.id}/ban`, {
method: "PATCH",
expectedStatus: 204,
});
......@@ -49,7 +52,7 @@ export const ban = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
export const unban = createAsyncAction(
......@@ -58,7 +61,7 @@ export const unban = createAsyncAction(
*/
async (user) => {
try {
await fetch(`/users/${user.id}/unban`, {
await fetchApi(`/users/${user.id}/unban`, {
method: "PATCH",
expectedStatus: 204,
});
......@@ -66,5 +69,57 @@ export const unban = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
export const inviteToJitsi = createAsyncAction(
/**
* @param {number} userId
*/
async (user) => {
try {
const body = JSON.stringify({
allowed: true,
});
await fetchApi(`/users/${user.id}/jitsi`, {
method: "PATCH",
body,
expectedStatus: 204,
});
return successResult(user);
} catch (err) {
return errorResult([], err.toString());
}
},
);
export const refreshAccessToken = async () => {
const { isAuthenticated } = AuthStore.getRawState();
if (!isAuthenticated) {
return;
}
try {
await keycloak.updateToken(60);
console.info("[auth] access token refreshed");
} catch (exc) {
console.warn(
"[auth] could not refresh the access token, refresh token possibly expired, logging out",
);
Sentry.setUser(null);
AuthStore.update((state) => {
state.isAuthenticated = false;
state.user = null;
state.showJitsiInvitePopup = false;
state.jitsiPopupDimissed = false;
});
PostStore.update((state) => {
state.filters.showPendingProposals = false;
updateWindowPosts(state);
});
}
};
import baseFetch from "unfetch";
import { AuthStore } from "./stores";
export const fetch = async (
export const fetchApi = async (
url,
{ headers = {}, expectedStatus = 200, method = "GET", body = null } = {}
{ headers = {}, expectedStatus = 200, method = "GET", body = null } = {},
) => {
const { isAuthenticated, user } = AuthStore.getRawState();
......@@ -16,10 +14,11 @@ export const fetch = async (
headers["Content-Type"] = "application/json";
}
const response = await baseFetch(process.env.REACT_APP_API_BASE_URL + url, {
const response = await fetch(process.env.REACT_APP_API_BASE_URL + url, {
body,
method,
headers,
redirect: "follow",
});
if (!!expectedStatus && response.status !== expectedStatus) {
......
import React from "react";
import React, { useState } from "react";
import { NavLink } from "react-router-dom";
import useWindowSize from "@rooks/use-window-size";
import classNames from "classnames";
const Footer = () => {
const { innerWidth } = useWindowSize();
const [showCfMenu, setShowCfMenu] = useState(false);
const [showOtherMenu, setShowOtherMenu] = useState(false);
const isLg = innerWidth >= 1024;
return (
<footer className="footer bg-grey-700 text-white">
<div className="footer__main py-4 lg:py-16 container container--default">
......@@ -12,17 +19,25 @@ const Footer = () => {
className="w-32 md:w-40 pb-6"
/>
<p className="para hidden md:block md:mb-4 lg:mb-0 text-grey-200">
Piráti, 2021. Všechna práva vyhlazena. Sdílejte a nechte ostatní
Piráti, 2024. Všechna práva vyhlazena. Sdílejte a nechte ostatní
sdílet za stejných podmínek.
</p>
</section>
<section className="footer__main-links bg-grey-700 text-white lg:grid grid-cols-3 gap-4">
<section className="footer__main-links bg-grey-700 text-white lg:grid grid-cols-2 gap-4">
<div className="pt-8 pb-4 lg:py-0">
<div className="footer-collapsible">
<span className="text-xl uppercase text-white footer-collapsible__toggle">
CF 2021
<span
className={classNames(
"text-xl uppercase text-white footer-collapsible__toggle",
{
"footer-collapsible__toggle--open": showCfMenu,
}
)}
onClick={() => setShowCfMenu(!showCfMenu)}
>
CF 2024
</span>{" "}
<div className="">
<div className={showCfMenu || isLg ? "" : "hidden"}>
<ul className="mt-6 space-y-2 text-grey-200">
<li>
<NavLink to="/">Přímý přenos</NavLink>
......@@ -33,16 +48,27 @@ const Footer = () => {
<li>
<NavLink to="/protocol">Zápis</NavLink>
</li>
<li>
<NavLink to="/about">Co je to celostátní fórum?</NavLink>
</li>
</ul>
</div>
</div>
</div>
<div className="py-4 lg:py-0 border-t border-grey-400 lg:border-t-0">
<div className="footer-collapsible">
<span className="text-xl uppercase text-white footer-collapsible__toggle">
<span
className={classNames(
"text-xl uppercase text-white footer-collapsible__toggle",
{
"footer-collapsible__toggle--open": showOtherMenu,
}
)}
onClick={() => setShowOtherMenu(!showOtherMenu)}
>
Otevřenost
</span>{" "}
<div className="">
<div className={showOtherMenu || isLg ? "" : "hidden"}>
<ul className="mt-6 space-y-2 text-grey-200">
<li>
<a href="https://ucet.pirati.cz">Transparentní účet</a>
......
......@@ -7,7 +7,7 @@ import classNames from "classnames";
import Button from "components/Button";
import { AuthStore, GlobalInfoStore } from "stores";
const Navbar = () => {
const Navbar = ({ onGetHelp }) => {
const { innerWidth } = useWindowSize();
const [showMenu, setShowMenu] = useState();
const { keycloak } = useKeycloak();
......@@ -36,10 +36,12 @@ const Navbar = () => {
};
const connectionIndicator = (
<div className="inline-flex items-center order-first md:order-last md:ml-8 lg:order-first lg:mr-8 lg:ml-0">
<div className="inline-flex items-center">
<span
className="relative inline-flex h-4 w-4 mr-4"
title={connectionStateCaption}
data-tip={connectionStateCaption}
data-tip-at="left"
aria-label={connectionStateCaption}
>
<span
className={classNames(
......@@ -75,7 +77,7 @@ const Navbar = () => {
to="/"
className="pl-4 font-bold text-xl lg:border-r lg:border-grey-300 lg:pr-8 hover:no-underline"
>
Celostátní fórum 2021
Celostátní fórum 2024
</NavLink>
</div>
<div className="navbar__menutoggle my-4 flex justify-end lg:hidden">
......@@ -107,8 +109,10 @@ const Navbar = () => {
</li>
</ul>
</div>
<div className="navbar__actions navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto self-start flex flex-row items-center">
{connectionIndicator}
<div className="navbar__actions navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto self-start flex flex-row items-center justify-between">
<div className="order-last lg:order-first lg:mr-8">
{connectionIndicator}
</div>
{!isAuthenticated && (
<Button className="btn--white joyride-login" onClick={login}>
Přihlásit se
......@@ -126,7 +130,9 @@ const Navbar = () => {
<button
onClick={logout}
className="text-grey-200 hover:text-white"
title="Odhlásit se"
aria-label="Odhlásit se"
data-tip="Odhlásit se"
data-tip-at="bottom"
>
<i className="ico--log-out"></i>
</button>
......
......@@ -18,14 +18,18 @@ const AnnouncementEditModal = ({
}) => {
const [text, setText] = useState(announcement.content);
const [link, setLink] = useState(announcement.link);
const [linkValid, setLinkValid] = useState(null);
const [noTextError, setNoTextError] = useState(false);
const [textError, setTextError] = useState(null);
const [linkError, setLinkError] = useState(null);
const onTextInput = (newText) => {
setText(newText);
if (newText !== "") {
setNoTextError(false);
if (newText.length > 1024) {
setTextError("Maximální délka příspěvku je 1024 znaků.");
} else {
setTextError(null);
}
}
};
......@@ -33,7 +37,11 @@ const AnnouncementEditModal = ({
setLink(newLink);
if (!!newLink) {
setLinkValid(urlRegex.test(newLink));
if (newLink.length > 1024) {
setLinkError("Maximální délka URL je 256 znaků.");
} else {
setLinkError(urlRegex.test(newLink) ? null : "Zadejte platnou URL.");
}
}
};
......@@ -46,12 +54,15 @@ const AnnouncementEditModal = ({
};
if (!text) {
setNoTextError(true);
setTextError("Před úpravou oznámení nezapomeňte vyplnit jeho obsah.");
preventAction = true;
} else if (!!text && text.length > 1024) {
setTextError("Maximální délka oznámení je 1024 znaků.");
preventAction = true;
}
if (announcement.type === "voting" && !link) {
setLinkValid(false);
setLinkError("Zadejte platnou URL.");
preventAction = true;
} else {
payload.link = link;
......@@ -67,7 +78,7 @@ const AnnouncementEditModal = ({
return (
<Modal containerClassName="max-w-lg" onRequestClose={onCancel} {...props}>
<form onSubmit={confirm}>
<Card>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>Upravit oznámení</CardHeadline>
......@@ -78,11 +89,7 @@ const AnnouncementEditModal = ({
<MarkdownEditor
value={text}
onChange={onTextInput}
error={
noTextError
? "Před úpravou oznámení nezapomeňte vyplnit jeho obsah."
: null
}
error={textError}
placeholder="Vyplňte text oznámení"
toolbarCommands={[
["bold", "italic", "strikethrough"],
......@@ -92,7 +99,7 @@ const AnnouncementEditModal = ({
<div
className={classNames("form-field mt-4", {
hidden: announcement.type !== "voting",
"form-field--error": linkValid === false,
"form-field--error": !!linkError,
})}
>
<div className="form-field__wrapper form-field__wrapper--shadowed">
......@@ -107,8 +114,8 @@ const AnnouncementEditModal = ({
<i className="ico--link"></i>
</div>
</div>
{linkValid === false && (
<div className="form-field__error">Zadejte platnou URL.</div>
{!!linkError && (
<div className="form-field__error">{linkError}</div>
)}
</div>
{error && (
......@@ -123,6 +130,7 @@ const AnnouncementEditModal = ({
color="blue-300"
className="text-sm"
loading={confirming}
disabled={textError || linkError || confirming}
type="submit"
>
Uložit
......
import React from "react";
import classNames from "classnames";
const Card = ({ children, elevation = 21, className }) => {
const cls = classNames("card", `elevation-${elevation}`, className);
return <div className={cls}>{children}</div>;
const Card = ({ children, className }, ref) => {
const cls = classNames("card", className);
return (
<div className={cls} ref={ref}>
{children}
</div>
);
};
export default Card;
export default React.forwardRef(Card);
import React from "react";
import classNames from "classnames";
const CardBody = ({ children, className }) => {
const CardBody = ({ children, className, ...props }) => {
const cls = classNames("card__body", className);
return <div className={cls}>{children}</div>;
return (
<div className={cls} {...props}>
{children}
</div>
);
};
export default CardBody;
......@@ -9,7 +9,7 @@ const NotYetStarted = ({ startAt }) => (
Jejda ...
</div>
<h1 className="head-alt-base md:head-alt-md lg:head-alt-xl mb-2">
Jednání ještě nebylo zahájeno :(
Jednání ještě nebylo zahájeno
</h1>
<p className="text-xl leading-snug mb-8">
<span>Jednání celostátního fóra ještě nezačalo. </span>
......
......@@ -7,13 +7,10 @@ import { markdownConverter } from "markdown";
import "react-mde/lib/styles/css/react-mde-toolbar.css";
import "./MarkdownEditor.css";
const MarkdownEditor = ({
value,
onChange,
error,
placeholder = "",
...props
}) => {
const MarkdownEditor = (
{ value, onChange, error, placeholder = "", ...props },
ref
) => {
const [selectedTab, setSelectedTab] = useState("write");
const classes = {
......@@ -36,6 +33,7 @@ const MarkdownEditor = ({
return (
<div className={classNames("form-field", { "form-field--error": !!error })}>
<ReactMde
ref={ref}
value={value}
onChange={onChange}
selectedTab={selectedTab}
......@@ -53,4 +51,4 @@ const MarkdownEditor = ({
);
};
export default MarkdownEditor;
export default React.forwardRef(MarkdownEditor);
......@@ -21,11 +21,16 @@ const ModalConfirm = ({
}) => {
return (
<Modal onRequestClose={onClose} {...props}>
<Card>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>{title}</CardHeadline>
<button onClick={onClose} type="button">
<button
onClick={onClose}
type="button"
data-tip="Zavřít"
aria-label="Zavřít"
>
<i className="ico--cross"></i>
</button>
</div>
......
import React from "react";
import Chip from "components/Chip";
// import Chip from "components/Chip";
export { default as Beacon } from "./Beacon";
......@@ -9,11 +9,12 @@ export const steps = [
target: "body",
content: (
<>
<h1 className="head-alt-sm mb-4">Vítej na celostátním fóru 2021</h1>
<h1 className="head-alt-sm mb-4">Vítej na celostátním fóru 2024</h1>
<p className="leading-snug text-base">
Letošní Pirátské fórum bude online. Abychom to celé zvládli,
připravili jsme tuhle aplikaci, která se snaží alespoň částečně
nahradit fyzickou přítomnost. Nejprve si vysvětlíme, jak funguje.
Víme, že volebního zasedání se nemohou zúčastnit všichni.
Abychom nepřítomným umožnili zasedání lépe sledovat, připravili
jsme tuhle aplikaci, která umožňuje zasáhnout do rozpravy.
Nejprve si vysvětlíme, jak funguje.
</p>
</>
),
......@@ -60,24 +61,6 @@ export const steps = [
<p>
<strong>Běžné příspěvky</strong> se zobrazí ihned po přidání.
</p>
<p>
<strong>Návrhy postupu</strong> po přidání nejprve zkontroluje
předsedající a pokud sezná, že je takový návrh přípusný, prohlásí ho
za{" "}
<Chip color="blue-300" condensed>
hlasovatelný
</Chip>
. Pro vyjádření podpory používej palce. Na základě míry podpory
předsedající buď návrh označí za{" "}
<Chip color="green-400" condensed>
schválený
</Chip>
, nebo za{" "}
<Chip color="red-600" condensed>
zamítnutý
</Chip>
.
</p>
<p>
U příspěvků se též zobrazuje celková míra podpory. Legenda barevného
odlišení je následující:
......@@ -110,10 +93,32 @@ export const steps = [
je označen příspěvek, který zatím není ohodnocen.
</li>
</ul>
<p>
<strong>Návrhy postupui</strong> po přidání nejprve zkontroluje předsedající a pokud sezná,
že je takový návrh přípusný, prohlásí ho za hlasovatelný a předloží k hlasování
v plénu. Na základě toho návrh předsedající označí za schválený, nebo za zamítnutý.
</p>
</div>
</>
),
placement: "center",
},
{
target: ".joyride-filters",
content: (
<>
<h1 className="head-alt-sm mb-4">Filtrování a řazení příspěvků</h1>
<div className="leading-snug text-base space-y-2">
<p>
Příspěvky v rozpravě můžeš filtrovat <strong>podle typu</strong>{" "}
(návrhy/příspěvky), <strong>podle stavu</strong>{" "}
(aktivní/archivované) a můžeš taky přepínat jejich{" "}
<strong>řazení</strong> (podle podpory, podle času přidání).
</p>
</div>
</>
),
placement: "right",
placement: "bottom",
},
{
target: ".joyride-announcements",
......@@ -135,7 +140,7 @@ export const steps = [
<>
<h1 className="head-alt-sm mb-4">To je vše!</h1>
<p className="leading-snug text-base">
Ať se ti letošní „CFko“ líbí i v těchto ztížených podmínkách.
Ať se ti letošní „CFko“ líbí.
</p>
</>
),
......
......@@ -23,11 +23,13 @@ const Post = ({
currentUser,
supportThreshold,
canThumb,
reportSeen = true,
onLike,
onDislike,
onHide,
onBanUser,
onUnbanUser,
onInviteUser,
onAnnounceProcedureProposal,
onAcceptProcedureProposal,
onRejectProcedureProposal,
......@@ -35,20 +37,21 @@ const Post = ({
onEdit,
onArchive,
onSeen,
...props
}) => {
const { ref, inView } = useInView({
threshold: 0.8,
trackVisibility: true,
delay: 1500,
skip: seen,
delay: 1000,
skip: !reportSeen,
triggerOnce: true,
});
useEffect(() => {
if (!seen && inView && onSeen) {
if (inView && onSeen) {
onSeen();
}
});
}, [inView, onSeen]);
const wrapperClassName = classNames(
"flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2 transition duration-500",
......@@ -88,7 +91,7 @@ const Post = ({
key="state__pending"
condensed
color="grey-500"
title="Návrh čekající na zpracování"
aria-label="Návrh čekající na zpracování"
>
Čeká na zpracování
</Chip>
......@@ -98,7 +101,7 @@ const Post = ({
key="state__announced"
condensed
color="blue-300"
title="Návrh k hlasování"
aria-label="Návrh k hlasování"
>
K hlasování
</Chip>
......@@ -108,7 +111,7 @@ const Post = ({
key="state__accepted"
condensed
color="green-400"
title="Schválený návrh"
aria-label="Schválený návrh"
>
Schválený
</Chip>
......@@ -118,7 +121,7 @@ const Post = ({
key="state__rejected"
condensed
color="red-600"
title="Zamítnutý návrh"
aria-label="Zamítnutý návrh"
>
Zamítnutý
</Chip>
......@@ -128,7 +131,7 @@ const Post = ({
key="state__rejected-by-chairmen"
condensed
color="red-600"
title="Návrh zamítnutý předsedajícím"
aria-label="Návrh zamítnutý předsedajícím"
>
Zamítnutý předs.
</Chip>
......@@ -167,6 +170,7 @@ const Post = ({
(currentUser && currentUser.id === author.id && !currentUser.isBanned);
const showBanAction = isChairman && !author.isBanned;
const showUnbanAction = isChairman && author.isBanned;
const showInviteAction = isChairman;
const showHideAction = isChairman && !archived;
const showArchiveAction = isChairman && !archived;
......@@ -179,6 +183,7 @@ const Post = ({
showEditAction,
showBanAction,
showUnbanAction,
showInviteAction,
showHideAction,
showArchiveAction,
].some((item) => !!item);
......@@ -190,21 +195,23 @@ const Post = ({
const thumbsVisible = !archived && (type === "post" || state === "announced");
return (
<div className={wrapperClassName} ref={ref}>
<div className={wrapperClassName} ref={ref} {...props}>
<img
src={`https://a.pirati.cz/piratar/200/${author.username}.jpg`}
className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3 object-cover"
className="w-8 h-8 lg:w-14 lg:h-14 mr-3 rounded object-cover"
alt={author.name}
/>
<div className="flex-1">
<div className="flex-1 overflow-hidden">
<div className="mb-1">
<div className="flex justify-between items-start xl:items-center">
<div className="flex flex-col xl:flex-row xl:items-center">
<div className="flex flex-col xl:flex-row xl:items-center">
<span className="font-bold">{author.name}</span>
<div className="mt-1 xl:mt-0 xl:ml-2 leading-tight">
<span className="text-grey-200 text-sm">{author.group}</span>
<span className="text-grey-200 ml-1 text-xs">
<span className="text-grey-200 text-xs sm:text-sm">
{author.group}
</span>
<span className="text-grey-200 ml-1 text-xs sm:text-sm">
@{" "}
{format(
datetime,
......@@ -235,13 +242,13 @@ const Post = ({
)}
<PostScore
className="ml-2"
score={ranking.score}
hasDislikes={ranking.dislikes > 0}
postType={type}
ranking={ranking}
rankingReadonly={!thumbsVisible}
supportThreshold={supportThreshold}
/>
{showActions && (
<DropdownMenu right className="pl-4">
<DropdownMenu right className="pl-4 static">
{showAnnounceAction && (
<DropdownMenuItem
onClick={onAnnounceProcedureProposal}
......@@ -291,6 +298,13 @@ const Post = ({
title="Odblokovat uživatele"
/>
)}
{showInviteAction && (
<DropdownMenuItem
onClick={onInviteUser}
icon="ico--phone"
title="Pozvat uživatele do Jitsi"
/>
)}
{showHideAction && (
<DropdownMenuItem
onClick={onHide}
......@@ -314,7 +328,7 @@ const Post = ({
{labels}
</div>
<div
className="text-sm lg:text-base text-black leading-normal content-block"
className="text-sm lg:text-base text-black leading-normal content-block overflow-x-auto overflow-y-hidden mt-1"
dangerouslySetInnerHTML={htmlContent}
></div>
</div>
......@@ -322,4 +336,4 @@ const Post = ({
);
};
export default Post;
export default React.memo(Post);
......@@ -15,13 +15,17 @@ const PostEditModal = ({
...props
}) => {
const [text, setText] = useState(post.content);
const [noTextError, setNoTextError] = useState(false);
const [textError, setTextError] = useState(null);
const onTextInput = (newText) => {
setText(newText);
if (newText !== "") {
setNoTextError(false);
if (newText.length >= 1024) {
setTextError("Maximální délka příspěvku je 1024 znaků.");
} else {
setTextError(null);
}
}
};
......@@ -31,14 +35,14 @@ const PostEditModal = ({
if (!!text) {
onConfirm(text);
} else {
setNoTextError(true);
setTextError("Před upravením příspěvku nezapomeňte vyplnit jeho obsah.");
}
};
return (
<Modal containerClassName="max-w-xl" onRequestClose={onCancel} {...props}>
<form onSubmit={confirm}>
<Card>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>Upravit text příspěvku</CardHeadline>
......@@ -49,15 +53,11 @@ const PostEditModal = ({
<MarkdownEditor
value={text}
onChange={onTextInput}
error={
noTextError
? "Před upravením příspěvku nezapomeňte vyplnit jeho obsah."
: null
}
error={textError}
placeholder="Vyplňte text příspěvku"
toolbarCommands={[
["header", "bold", "italic", "strikethrough"],
["link", "quote", "image"],
["link", "quote"],
["unordered-list", "ordered-list"],
]}
/>
......@@ -72,6 +72,7 @@ const PostEditModal = ({
hoverActive
color="blue-300"
className="text-sm"
disabled={textError || confirming}
loading={confirming}
onClick={confirm}
>
......
import React from "react";
import React, { useCallback, useMemo, useState } from "react";
import classNames from "classnames";
import Post from "./Post";
......@@ -16,6 +16,7 @@ const PostList = ({
onHide,
onBanUser,
onUnbanUser,
onInviteUser,
onAnnounceProcedureProposal,
onAcceptProcedureProposal,
onRejectProcedureProposal,
......@@ -29,12 +30,16 @@ const PostList = ({
responderFn(post);
};
const windowSize = 20;
const [window, setWindow] = useState(windowSize);
const onPostLike = buildHandler(onLike);
const onPostDislike = buildHandler(onDislike);
const onPostEdit = buildHandler(onEdit);
const onPostHide = buildHandler(onHide);
const onPostBanUser = buildHandler(onBanUser);
const onPostUnbanUser = buildHandler(onUnbanUser);
const onPostInviteUser = buildHandler(onInviteUser);
const onPostArchive = buildHandler(onArchive);
const onPostAnnounceProcedureProposal = buildHandler(
onAnnounceProcedureProposal
......@@ -45,47 +50,60 @@ const PostList = ({
onRejectProcedureProposalByChairman
);
const onPostSeen = (post) => () => {
onSeen(post);
};
const onPostSeen = useCallback(
(post) => () => {
if (!post.seen) {
onSeen(post);
}
// Once last post in window is reached, attempt show more.
if (items.indexOf(post) === window - 1) {
setWindow(window + windowSize);
}
},
[items, onSeen, window]
);
const windowItems = useMemo(() => {
return items.slice(0, window).filter((item) => !item.hidden);
}, [items, window]);
return (
<div className={classNames("space-y-px", className)}>
{items
.filter((item) => !item.hidden)
.map((item) => (
<Post
key={item.id}
datetime={item.datetime}
author={item.author}
type={item.type}
state={item.state}
content={item.contentHtml}
ranking={item.ranking}
historyLog={item.historyLog}
modified={item.modified}
seen={item.seen}
archived={item.archived}
dimIfArchived={dimArchived}
currentUser={currentUser}
supportThreshold={supportThreshold}
canThumb={canThumb}
onLike={onPostLike(item)}
onDislike={onPostDislike(item)}
onHide={onPostHide(item)}
onBanUser={onPostBanUser(item)}
onUnbanUser={onPostUnbanUser(item)}
onAnnounceProcedureProposal={onPostAnnounceProcedureProposal(item)}
onAcceptProcedureProposal={onPostAcceptProcedureProposal(item)}
onRejectProcedureProposal={onPostRejectProcedureProposal(item)}
onRejectProcedureProposalByChairman={onPostRejectProcedureProposalByChairman(
item
)}
onEdit={onPostEdit(item)}
onArchive={onPostArchive(item)}
onSeen={onPostSeen(item)}
/>
))}
{windowItems.map((item, idx) => (
<Post
key={item.id}
datetime={item.datetime}
author={item.author}
type={item.type}
state={item.state}
content={item.contentHtml}
ranking={item.ranking}
modified={item.modified}
seen={item.seen}
reportSeen={!item.seen || idx === window - 1}
archived={item.archived}
dimIfArchived={dimArchived}
currentUser={currentUser}
supportThreshold={supportThreshold}
canThumb={canThumb}
onLike={onPostLike(item)}
onDislike={onPostDislike(item)}
onHide={onPostHide(item)}
onBanUser={onPostBanUser(item)}
onUnbanUser={onPostUnbanUser(item)}
onInviteUser={onPostInviteUser(item)}
onAnnounceProcedureProposal={onPostAnnounceProcedureProposal(item)}
onAcceptProcedureProposal={onPostAcceptProcedureProposal(item)}
onRejectProcedureProposal={onPostRejectProcedureProposal(item)}
onRejectProcedureProposalByChairman={onPostRejectProcedureProposalByChairman(
item
)}
onEdit={onPostEdit(item)}
onArchive={onPostArchive(item)}
onSeen={onPostSeen(item)}
/>
))}
{showAddPostCta && !items.length && (
<p className="p-4 lg:p-0 lg:py-3 leading-snug text-sm md:text-base">
Nikdo zatím žádný odpovídající příspěvek do rozpravy nepřidal. Budeš
......
......@@ -2,27 +2,31 @@ import React from "react";
import classNames from "classnames";
const PostScore = ({
score,
hasDislikes,
postType,
ranking,
supportThreshold,
rankingReadonly,
className,
}) => {
const coloring = rankingReadonly
? "bg-grey-125 text-grey-200"
: {
const { score, dislikes } = ranking;
const highlight = postType === "procedure-proposal" && !rankingReadonly;
const coloring = highlight
? {
"bg-red-600 text-white": score < 0,
"bg-grey-125 text-grey-200": score === 0 && score < supportThreshold,
"bg-yellow-400 text-grey-300":
score > 0 && hasDislikes && score < supportThreshold,
score > 0 && dislikes > 0 && score < supportThreshold,
"bg-green-400 text-white":
score >= supportThreshold || (score > 0 && !hasDislikes),
};
score >= supportThreshold || (score > 0 && dislikes <= 0),
}
: "bg-grey-125 text-grey-200";
let title;
if (!rankingReadonly) {
if (hasDislikes) {
if (postType === "procedure-proposal") {
if (rankingReadonly) {
title = `Návrh postupu získal podporu ${score} hlasů.`;
} else if (dislikes > 0) {
if (score < supportThreshold) {
title = `Aktuální podpora je ${score} hlasů, pro získání podpory skupiny členů chybí ještě ${
supportThreshold - score
......@@ -45,17 +49,13 @@ const PostScore = ({
className
)}
style={{ cursor: "help" }}
title={title}
aria-label={title}
data-tip={title}
data-type="dark"
data-place="top"
>
<i className="ico--power" />
<span className="font-bold">
{!rankingReadonly && hasDislikes && (
<span>
{score} z {supportThreshold}
</span>
)}
{!(!rankingReadonly && hasDislikes) && score.toString()}
</span>
<span className="font-bold">{score}</span>
</span>
);
};
......
......@@ -27,7 +27,7 @@ const RejectPostModalConfirm = ({
}) => {
return (
<Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
<Card>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>{title}</CardHeadline>
......
......@@ -24,7 +24,7 @@ const ProgramEntryEditModal = ({
return (
<Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
<Card>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>Upravit název programového bodu</CardHeadline>
......
......@@ -11,8 +11,8 @@ import { urlRegex } from "utils";
const AddAnnouncementForm = ({ className }) => {
const [text, setText] = useState("");
const [link, setLink] = useState("");
const [linkValid, setLinkValid] = useState(null);
const [noTextError, setNoTextError] = useState(false);
const [textError, setTextError] = useState(null);
const [linkError, setLinkError] = useState(null);
const [type, setType] = useState("announcement");
const [adding, addingError] = useActionState(addAnnouncement, {
......@@ -25,7 +25,11 @@ const AddAnnouncementForm = ({ className }) => {
setText(newText);
if (newText !== "") {
setNoTextError(false);
if (newText.length > 1024) {
setTextError("Maximální délka příspěvku je 1024 znaků.");
} else {
setTextError(null);
}
}
};
......@@ -33,11 +37,17 @@ const AddAnnouncementForm = ({ className }) => {
setLink(newLink);
if (!!newLink) {
setLinkValid(urlRegex.test(newLink));
if (newLink.length > 1024) {
setLinkError("Maximální délka URL je 256 znaků.");
} else {
setLinkError(urlRegex.test(newLink) ? null : "Zadejte platnou URL.");
}
}
};
const onAdd = async (evt) => {
evt.preventDefault();
let preventAction = false;
const payload = {
content: text,
......@@ -45,12 +55,15 @@ const AddAnnouncementForm = ({ className }) => {
};
if (!text) {
setNoTextError(true);
setTextError("Před přidáním oznámení nezapomeňte vyplnit jeho obsah.");
preventAction = true;
} else if (!!text && text.length > 1024) {
setTextError("Maximální délka oznámení je 1024 znaků.");
preventAction = true;
}
if (type === "voting" && !link) {
setLinkValid(false);
setLinkError("Zadejte platnou URL.");
preventAction = true;
} else {
payload.link = link;
......@@ -65,13 +78,13 @@ const AddAnnouncementForm = ({ className }) => {
if (!result.error) {
setText("");
setLink("");
setNoTextError(false);
setLinkValid(null);
setTextError(null);
setLinkError(null);
}
};
return (
<div className={className}>
<form className={className} onSubmit={onAdd}>
{addingError && (
<ErrorMessage>
Při přidávání oznámení došlo k problému: {addingError}.
......@@ -107,11 +120,7 @@ const AddAnnouncementForm = ({ className }) => {
<MarkdownEditor
value={text}
onChange={onTextInput}
error={
noTextError
? "Před přidáním oznámení nezapomeňte vyplnit jeho obsah."
: null
}
error={textError}
placeholder="Vyplňte text oznámení"
toolbarCommands={[
["bold", "italic", "strikethrough"],
......@@ -123,7 +132,7 @@ const AddAnnouncementForm = ({ className }) => {
<div
className={classNames("form-field", {
hidden: type !== "voting",
"form-field--error": linkValid === false,
"form-field--error": !!linkError,
})}
>
<div className="form-field__wrapper form-field__wrapper--shadowed">
......@@ -138,23 +147,21 @@ const AddAnnouncementForm = ({ className }) => {
<i className="ico--link"></i>
</div>
</div>
{linkValid === false && (
<div className="form-field__error">Zadejte platnou URL.</div>
)}
{!!linkError && <div className="form-field__error">{linkError}</div>}
</div>
</div>
<Button
onClick={onAdd}
type="submit"
className="text-sm mt-4"
hoverActive
loading={adding}
disabled={adding}
disabled={textError || linkError || adding}
fullwidth
>
Přidat oznámení
</Button>
</div>
</form>
);
};
......
import React, { useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import useOutsideClick from "@rooks/use-outside-click";
import useTimeout from "@rooks/use-timeout";
import classNames from "classnames";
import { addPost, addProposal } from "actions/posts";
import Button from "components/Button";
import { Card, CardBody } from "components/cards";
import ErrorMessage from "components/ErrorMessage";
import MarkdownEditor from "components/mde/MarkdownEditor";
import { useActionState } from "hooks";
const AddPostForm = ({ className }) => {
const AddPostForm = ({ className, canAddProposal }) => {
const cardRef = useRef();
const editorRef = useRef();
const [expanded, setExpanded] = useState(false);
const [text, setText] = useState("");
const [showAddConfirm, setShowAddConfirm] = useState(false);
const [type, setType] = useState("post");
const [noTextError, setNoTextError] = useState(false);
const [error, setError] = useState(null);
const [addingPost, addingPostError] = useActionState(addPost, {
content: text,
});
const [addingProposal, addingProposalError] = useActionState(addPost, {
const [addingProposal, addingProposalError] = useActionState(addProposal, {
content: text,
});
const apiError = addingPostError || addingProposalError;
const is429ApiError =
apiError &&
apiError.toString().indexOf("Unexpected status code 429") !== -1;
const onOutsideClick = useCallback(() => {
setExpanded(false);
}, [setExpanded]);
const onWrite = useCallback(
(evt) => {
setShowAddConfirm(false);
if (!expanded) {
setExpanded(true);
setTimeout(() => {
if (
editorRef.current &&
editorRef.current.finalRefs.textarea.current
) {
editorRef.current.finalRefs.textarea.current.focus();
}
}, 0);
}
},
[setExpanded, expanded, setShowAddConfirm]
);
const hideAddConfirm = useCallback(() => {
setShowAddConfirm(false);
}, [setShowAddConfirm]);
useOutsideClick(cardRef, onOutsideClick);
const { start: enqueueHideAddConfirm } = useTimeout(hideAddConfirm, 2000);
const onTextInput = (newText) => {
setText(newText);
if (newText !== "") {
setNoTextError(false);
if (newText.length >= 1024) {
setError("Maximální délka příspěvku je 1024 znaků.");
} else {
setError(null);
}
}
};
const onAdd = async (evt) => {
evt.preventDefault();
if (!!text) {
const result = await (type === "post" ? addPost : addProposal).run({
content: text,
});
if (!error) {
const result = await (type === "post" ? addPost : addProposal).run({
content: text,
});
if (!result.error) {
setText("");
if (!result.error) {
setText("");
setExpanded(false);
setShowAddConfirm(true);
enqueueHideAddConfirm();
}
}
} else {
setNoTextError(true);
setError("Před přidáním příspěvku nezapomeňte vyplnit jeho obsah.");
}
};
const wrapperClass = classNames(
className,
"hover:elevation-16 transition duration-500",
{
"elevation-4 cursor-text": !expanded && !showAddConfirm,
"lg:elevation-16 container-padding--zero lg:container-padding--auto": expanded,
}
);
return (
<div className={className}>
{addingPostError && (
<ErrorMessage>
Při přidávání příspěvku došlo k problému: {addingPostError}.
</ErrorMessage>
)}
{addingProposalError && (
<ErrorMessage>
Při přidávání příspěvku došlo k problému: {addingProposalError}.
</ErrorMessage>
)}
<MarkdownEditor
value={text}
onChange={onTextInput}
error={
noTextError
? "Před přidáním příspěvku nezapomeňte vyplnit jeho obsah."
: null
<Card className={wrapperClass} ref={cardRef}>
<span
className={classNames("alert items-center transition duration-500", {
"alert--success": showAddConfirm,
"alert--light": !showAddConfirm,
hidden: expanded,
})}
onClick={onWrite}
>
<i
className={classNames("alert__icon text-lg mr-4", {
"ico--checkmark": showAddConfirm,
"ico--pencil": !showAddConfirm,
})}
/>
{showAddConfirm && <span>Příspěvek byl přidán.</span>}
{!showAddConfirm && <span>Napiš nový příspěvek ...</span>}
</span>
<CardBody
className={
"p-4 lg:p-8 " + (showAddConfirm || !expanded ? "hidden" : "")
}
placeholder="Vyplňte text vašeho příspěvku"
toolbarCommands={[
["header", "bold", "italic", "strikethrough"],
["link", "quote"],
["unordered-list", "ordered-list"],
]}
/>
<div className="form-field" onChange={(evt) => setType(evt.target.value)}>
<div className="form-field__wrapper">
<div className="radio form-field__control">
<label>
<input type="radio" name="postType" value="post" defaultChecked />
>
<form className="space-y-4" onSubmit={onAdd}>
{apiError && is429ApiError && (
<div className="alert alert--warning">
<i className="alert__icon ico--clock text-lg" />
<span>
Přidávám <strong>běžný příspěvek</strong>
<strong>Zpomal!</strong> Další příspěvek můžeš přidat nejdříve
po 1 minutě od přidání posledního.
</span>
</label>
</div>
</div>
)}
{apiError && !is429ApiError && (
<ErrorMessage>
Při přidávání příspěvku došlo k problému: {apiError}.
</ErrorMessage>
)}
<div className="radio form-field__control">
<label>
<input type="radio" name="postType" value="procedure-proposal" />
<MarkdownEditor
ref={editorRef}
value={text}
onChange={onTextInput}
error={error}
placeholder="Vyplňte text vašeho příspěvku"
toolbarCommands={[
["header", "bold", "italic", "strikethrough"],
["link", "quote"],
["unordered-list", "ordered-list"],
]}
/>
{canAddProposal && (
<div
className="form-field"
onChange={(evt) => setType(evt.target.value)}
>
<div className="form-field__wrapper form-field__wrapper--freeform flex-col sm:flex-row">
<div className="radio form-field__control">
<label>
<input
type="radio"
name="postType"
value="post"
defaultChecked
/>
<span className="text-sm sm:text-base">
Přidávám <strong>běžný příspěvek</strong>
</span>
</label>
</div>
<div className="radio form-field__control ml-0 mt-4 sm:mt-0 sm:ml-4">
<label>
<input
type="radio"
name="postType"
value="procedure-proposal"
/>
<span className="text-sm sm:text-base">
Přidávám <strong>návrh postupu</strong>
</span>
</label>
</div>
</div>
</div>
)}
{type === "procedure-proposal" && (
<p className="alert alert--light text-sm">
<i className="alert__icon ico--info mr-2 text-lg hidden md:block" />
<span>
Přidávám <strong>návrh postupu</strong>
Návrh postupu se v rozpravě zobrazí až poté, co předsedající{" "}
<strong>posoudí jeho přijatelnost</strong>. Po odeslání proto
nepanikař, že jej hned nevidíš.
</span>
</label>
</p>
)}
<div className="space-x-4">
<Button
type="submit"
disabled={error || addingPost || addingProposal}
loading={addingPost || addingProposal}
fullwidth
hoverActive
className="text-sm xl:text-base"
>
{type === "post" && "Přidat příspěvek"}
{type === "procedure-proposal" && "Navrhnout postup"}
</Button>
<span className="text-sm text-grey-200 hidden lg:inline">
Pro pokročilejší formátování můžete používat{" "}
<a
href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet"
className="underline"
target="_blank"
rel="noreferrer noopener"
>
Markdown
</a>
.
</span>
</div>
</div>
</div>
<div className="space-x-4">
<Button
onClick={onAdd}
disabled={addingPost || addingProposal}
loading={addingPost || addingProposal}
fullwidth
hoverActive
>
{type === "post" && "Přidat příspěvek"}
{type === "procedure-proposal" && "Navrhnout postup"}
</Button>
<span className="text-sm text-grey-200 hidden lg:inline">
Pro pokročilejší formátování můžete používat{" "}
<a
href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet"
className="underline"
target="_blank"
rel="noreferrer noopener"
>
Markdown
</a>
.
</span>
</div>
</div>
</form>
</CardBody>
</Card>
);
};
......