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
Select Git revision
  • cf2023-euro
  • cf2023-offline
  • cf2024
  • cf2025
  • main
5 results

Target

Select target project
  • to/cf-online-ui
  • vpfafrin/cf2021
2 results
Select Git revision
  • master
1 result
Show changes
Showing
with 514 additions and 181 deletions
......@@ -2,7 +2,7 @@ import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { fetchApi } from "api";
import { AnnouncementStore } from "stores";
import {
announcementTypeMappingRev,
......@@ -15,7 +15,7 @@ import {
export const loadAnnouncements = createAsyncAction(
async () => {
try {
const resp = await fetch("/announcements");
const resp = await fetchApi("/announcements");
const data = await resp.json();
return successResult(data.data);
} catch (err) {
......@@ -33,7 +33,7 @@ export const loadAnnouncements = createAsyncAction(
});
}
},
}
},
);
/**
......@@ -47,7 +47,7 @@ export const addAnnouncement = createAsyncAction(
link,
type: announcementTypeMappingRev[type],
});
const resp = await fetch("/announcements", {
const resp = await fetchApi("/announcements", {
method: "POST",
body,
expectedStatus: 201,
......@@ -57,7 +57,7 @@ export const addAnnouncement = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
/**
......@@ -70,7 +70,7 @@ export const deleteAnnouncement = createAsyncAction(
*/
async (item) => {
try {
await fetch(`/announcements/${item.id}`, {
await fetchApi(`/announcements/${item.id}`, {
method: "DELETE",
expectedStatus: 204,
});
......@@ -78,33 +78,31 @@ export const deleteAnnouncement = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
/**
* Update content of an announcement.
* Update an announcement.
*/
export const updateAnnouncementContent = createAsyncAction(
export const updateAnnouncement = createAsyncAction(
/**
*
* @param {CF2021.Announcement} item
* @param {string} newContent
* @param {Object} payload
*/
async ({ item, newContent }) => {
async ({ item, payload }) => {
try {
const body = JSON.stringify({
content: newContent,
});
await fetch(`/announcements/${item.id}`, {
const body = JSON.stringify(payload);
await fetchApi(`/announcements/${item.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult({ item, newContent });
return successResult({ item, payload });
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
const { markSeen: storeSeen } = createSeenWriter(seenAnnouncementsLSKey);
......
import isArray from "lodash/isArray";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { GlobalInfoStore } from "stores";
export const loadConfig = createAsyncAction(
async () => {
try {
const resp = await fetch("/config");
const resp = await fetchApi("/config");
const payload = await resp.json();
if (!isArray(payload)) {
......@@ -27,9 +28,42 @@ export const loadConfig = createAsyncAction(
if (rawConfigItem.id === "stream_url") {
state.streamUrl = rawConfigItem.value;
}
if (rawConfigItem.id === "websocket_url") {
state.websocketUrl = rawConfigItem.value;
}
if (rawConfigItem.id === "record_url") {
state.protocolUrl = rawConfigItem.value;
}
});
});
}
},
},
);
export const loadProtocol = createAsyncAction(
async () => {
const { protocolUrl } = GlobalInfoStore.getRawState();
try {
const resp = await fetch(protocolUrl);
if (resp.status !== 200) {
return errorResult([], `Unexpected status code ${resp.status}`);
}
return successResult(await resp.text());
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
GlobalInfoStore.update((state) => {
state.protocol = markdownConverter.makeHtml(result.payload);
});
}
},
},
);
......@@ -2,8 +2,8 @@ import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
import { PostStore } from "stores";
import { fetchApi } from "api";
import { AuthStore, PostStore } from "stores";
import {
createSeenWriter,
filterPosts,
......@@ -16,7 +16,7 @@ import {
export const loadPosts = createAsyncAction(
async () => {
try {
const resp = await fetch("/posts", { expectedStatus: 200 });
const resp = await fetchApi("/posts", { expectedStatus: 200 });
const data = await resp.json();
return successResult(data.data);
} catch (err) {
......@@ -36,13 +36,11 @@ export const loadPosts = createAsyncAction(
state.window = {
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
page: 1,
perPage: 20,
};
});
}
},
}
},
);
export const like = createAsyncAction(
......@@ -51,7 +49,7 @@ export const like = createAsyncAction(
*/
async (post) => {
try {
await fetch(`/posts/${post.id}/like`, {
await fetchApi(`/posts/${post.id}/like`, {
method: "PATCH",
expectedStatus: 204,
});
......@@ -64,11 +62,14 @@ export const like = createAsyncAction(
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote = "like";
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "like"
? "like"
: "none";
});
}
},
}
},
);
export const dislike = createAsyncAction(
......@@ -77,7 +78,7 @@ export const dislike = createAsyncAction(
*/
async (post) => {
try {
await fetch(`/posts/${post.id}/dislike`, {
await fetchApi(`/posts/${post.id}/dislike`, {
method: "PATCH",
expectedStatus: 204,
});
......@@ -90,11 +91,14 @@ export const dislike = createAsyncAction(
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote = "dislike";
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "dislike"
? "dislike"
: "none";
});
}
},
}
},
);
/**
......@@ -106,7 +110,7 @@ export const addPost = createAsyncAction(async ({ content }) => {
content,
type: postsTypeMappingRev["post"],
});
await fetch(`/posts`, { method: "POST", body, expectedStatus: 201 });
await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult();
} catch (err) {
return errorResult([], err.toString());
......@@ -122,7 +126,7 @@ export const addProposal = createAsyncAction(async ({ content }) => {
content,
type: postsTypeMappingRev["procedure-proposal"],
});
await fetch(`/posts`, { method: "POST", body, expectedStatus: 201 });
await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult();
} catch (err) {
return errorResult([], err.toString());
......@@ -138,7 +142,7 @@ export const hide = createAsyncAction(
*/
async (post) => {
try {
await fetch(`/posts/${post.id}`, {
await fetchApi(`/posts/${post.id}`, {
method: "DELETE",
expectedStatus: 204,
});
......@@ -146,7 +150,7 @@ export const hide = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
/**
......@@ -161,7 +165,7 @@ export const edit = createAsyncAction(
const body = JSON.stringify({
content: newContent,
});
await fetch(`/posts/${post.id}`, {
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -170,7 +174,22 @@ export const edit = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
},
{
shortCircuitHook: ({ args }) => {
const { user } = AuthStore.getRawState();
if (!user) {
return errorResult();
}
if (user && user.isBanned) {
return errorResult();
}
return false;
},
},
);
/**
......@@ -185,7 +204,7 @@ export const archive = createAsyncAction(
const body = JSON.stringify({
is_archived: true,
});
await fetch(`/posts/${post.id}`, {
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -194,7 +213,7 @@ export const archive = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
/**
......@@ -202,11 +221,12 @@ export const archive = createAsyncAction(
* @param {CF2021.ProposalPost} proposal
* @param {CF2021.ProposalPostState} state
*/
const updateProposalState = async (proposal, state) => {
const updateProposalState = async (proposal, state, additionalPayload) => {
const body = JSON.stringify({
state: postsStateMappingRev[state],
...(additionalPayload || {}),
});
await fetch(`/posts/${proposal.id}`, {
await fetchApi(`/posts/${proposal.id}`, {
method: "PUT",
body,
expectedStatus: 204,
......@@ -236,7 +256,7 @@ export const announceProposal = createAsyncAction(
return false;
},
}
},
);
/**
......@@ -246,22 +266,22 @@ export const acceptProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
(proposal) => {
return updateProposalState(proposal, "accepted");
({ proposal, archive }) => {
return updateProposalState(proposal, "accepted", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.state !== "announced") {
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
}
},
);
/**
......@@ -271,22 +291,22 @@ export const rejectProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
(proposal) => {
return updateProposalState(proposal, "rejected");
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.state !== "announced") {
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
}
},
);
/**
......@@ -296,22 +316,24 @@ export const rejectProposalByChairman = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
(proposal) => {
return updateProposalState(proposal, "rejected-by-chairman");
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected-by-chairman", {
is_archived: archive,
});
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (!["pending", "announced"].includes(args.state)) {
if (!["pending", "announced"].includes(args.proposal.state)) {
return errorResult();
}
return false;
},
}
},
);
const { markSeen: storeSeen } = createSeenWriter(seenPostsLSKey);
......
......@@ -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,
});
......@@ -27,11 +30,12 @@ export const loadMe = createAsyncAction(
state.user.id = result.payload.id;
state.user.group = result.payload.group;
state.user.isBanned = result.payload.is_banned;
state.user.secret = result.payload.secret || "";
}
});
}
},
}
},
);
export const ban = createAsyncAction(
......@@ -40,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,
});
......@@ -48,7 +52,7 @@ export const ban = createAsyncAction(
} catch (err) {
return errorResult([], err.toString());
}
}
},
);
export const unban = createAsyncAction(
......@@ -57,13 +61,65 @@ export const unban = createAsyncAction(
*/
async (user) => {
try {
await fetch(`/users/${user.id}/unban`, {
await fetchApi(`/users/${user.id}/unban`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(user);
} 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 { createAsyncAction, errorResult, successResult } from "pullstate";
import { GlobalInfoStore } from "stores";
import { connect } from "ws/connection";
import { loadAnnouncements } from "./announcements";
......@@ -7,8 +8,11 @@ import { loadPosts } from "./posts";
import { loadProgram } from "./program";
export const initializeWSChannel = createAsyncAction(async () => {
const { websocketUrl } = GlobalInfoStore.getRawState();
try {
const wsChannel = await connect({
url: websocketUrl,
onConnect: async ({ worker }) => {
// Re-load initial data once connected, this will ensure we won't lose
// any intermediate 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) {
......
......@@ -4,6 +4,7 @@ import classNames from "classnames";
const Button = ({
className,
bodyClassName,
icon,
color = "black",
hoverActive = true,
......@@ -11,6 +12,7 @@ const Button = ({
loading = false,
children,
routerTo,
bodyProps = {},
...props
}) => {
const btnClass = classNames(
......@@ -25,9 +27,13 @@ const Button = ({
className
);
const bodyClass = classNames("btn__body", bodyClassName);
const inner = (
<div className="btn__body-wrap">
<div className="btn__body">{children}</div>
<div className={bodyClass} {...bodyProps}>
{children}
</div>
{!!icon && (
<div className="btn__icon">
<i className={icon}></i>
......@@ -51,4 +57,4 @@ const Button = ({
);
};
export default Button;
export default React.memo(Button);
......@@ -26,4 +26,4 @@ const Chip = ({
);
};
export default Chip;
export default React.memo(Chip);
......@@ -25,4 +25,4 @@ const Dropdown = ({ value, options, onChange, className }) => {
);
};
export default Dropdown;
export default React.memo(Dropdown);
......@@ -9,4 +9,4 @@ const ErrorMessage = ({ className, children }) => {
);
};
export default ErrorMessage;
export default React.memo(ErrorMessage);
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>
......@@ -30,16 +45,30 @@ const Footer = () => {
<li>
<NavLink to="/program">Program</NavLink>
</li>
<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>
......
import React, { useCallback, useState } from "react";
import { isBrowser } from "react-device-detect";
import { NavLink } from "react-router-dom";
import { useKeycloak } from "@react-keycloak/web";
import useWindowSize from "@rooks/use-window-size";
import classNames from "classnames";
import Button from "components/Button";
import { AuthStore } from "stores";
import { AuthStore, GlobalInfoStore } from "stores";
const Navbar = () => {
const [showMenu, setShowMenu] = useState(isBrowser);
const Navbar = ({ onGetHelp }) => {
const { innerWidth } = useWindowSize();
const [showMenu, setShowMenu] = useState();
const { keycloak } = useKeycloak();
const { isAuthenticated, user } = AuthStore.useState();
const { connectionState } = GlobalInfoStore.useState();
const login = useCallback(() => {
keycloak.login();
......@@ -18,6 +21,47 @@ const Navbar = () => {
keycloak.logout();
}, [keycloak]);
const connectionStateCaption = {
connected: "Jsi online",
offline: "Jsi offline",
connecting: "Probíhá připojování",
}[connectionState];
const isLg = innerWidth >= 1024;
const indicatorClass = {
"bg-green-400": connectionState === "connected",
"bg-red-600": connectionState === "offline",
"bg-yellow-200": connectionState === "connecting",
};
const connectionIndicator = (
<div className="inline-flex items-center">
<span
className="relative inline-flex h-4 w-4 mr-4"
data-tip={connectionStateCaption}
data-tip-at="left"
aria-label={connectionStateCaption}
>
<span
className={classNames(
"animate-ping absolute inline-flex h-full w-full rounded-full opacity-75",
indicatorClass
)}
/>
<span
className={classNames(
"inline-flex rounded-full w-4 h-4",
indicatorClass
)}
/>
</span>
<span className="hidden md:block text-grey-200">
{connectionStateCaption}
</span>
</div>
);
return (
<nav className="navbar navbar--simple">
<div className="container container--wide navbar__content navbar__content--initialized">
......@@ -33,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">
......@@ -44,7 +88,7 @@ const Navbar = () => {
<i className="ico--menu text-3xl"></i>
</button>
</div>
{showMenu && (
{(showMenu || isLg) && (
<>
<div className="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto">
<ul className="navbar-menu text-white">
......@@ -58,16 +102,24 @@ const Navbar = () => {
Program
</NavLink>
</li>
<li className="navbar-menu__item">
<NavLink className="navbar-menu__link" to="/protocol">
Zápis
</NavLink>
</li>
</ul>
</div>
<div className="navbar__actions navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto self-start flex flex-col sm:flex-row lg:flex-col sm:space-x-4 space-y-2 sm:space-y-0 lg:space-y-2 xl:flex-row xl:space-x-2 xl:space-y-0">
<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" onClick={login}>
<Button className="btn--white joyride-login" onClick={login}>
Přihlásit se
</Button>
)}
{isAuthenticated && (
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-4 joyride-login">
<span className="head-heavy-2xs">{user.name}</span>
<div className="avatar avatar--2xs">
<img
......@@ -75,7 +127,13 @@ const Navbar = () => {
alt="Avatar"
/>
</div>
<button onClick={logout}>
<button
onClick={logout}
className="text-grey-200 hover:text-white"
aria-label="Odhlásit se"
data-tip="Odhlásit se"
data-tip-at="bottom"
>
<i className="ico--log-out"></i>
</button>
</div>
......
......@@ -8,9 +8,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => {
<button
className={classNames("flex items-center space-x-1", {
"cursor-pointer": !readOnly,
"cursor-default": readOnly,
"cursor-not-allowed": readOnly,
"text-blue-300": myVote === "like",
"text-grey-200 hover:text-blue-300": myVote !== "like",
"text-grey-200 ": myVote !== "like",
"hover:text-blue-300": myVote !== "like" && !readOnly,
})}
disabled={readOnly}
onClick={onLike}
......@@ -21,9 +22,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => {
<button
className={classNames("flex items-center space-x-1", {
"cursor-pointer": !readOnly,
"cursor-default": readOnly,
"cursor-not-allowed": readOnly,
"text-red-600": myVote === "dislike",
"text-grey-200 hover:text-red-600": myVote !== "dislike",
"text-grey-200": myVote !== "dislike",
"hover:text-red-600": myVote !== "dislike" && !readOnly,
})}
disabled={readOnly}
onClick={onDislike}
......
......@@ -20,7 +20,7 @@ const Announcement = ({
onSeen,
}) => {
const { ref, inView } = useInView({
threshold: 1,
threshold: 0.8,
trackVisibility: true,
delay: 1500,
skip: seen,
......@@ -59,7 +59,7 @@ const Announcement = ({
const chipLabel = {
"rejected-procedure-proposal": "Zamítnutý návrh postupu",
"suggested-procedure-proposal": "Přijatelný návrh postupu",
"suggested-procedure-proposal": "Návrh postupu k hlasování",
"accepted-procedure-proposal": "Schválený návrh postupu",
voting: "Rozhodující hlasování",
announcement: "Oznámení předsedajícího",
......@@ -75,6 +75,10 @@ const Announcement = ({
"announcement",
].includes(type);
const htmlContent = {
__html: content,
};
return (
<div className={wrapperClassName} ref={ref}>
<div className="flex items-center justify-between mb-2">
......@@ -105,19 +109,22 @@ const Announcement = ({
{showEdit && (
<DropdownMenuItem
onClick={onEdit}
icon="ico--edit-pencil"
icon="ico--pencil"
title="Upravit"
/>
)}
<DropdownMenuItem
onClick={onDelete}
icon="ico--trashcan"
icon="ico--bin"
title="Smazat"
/>
</DropdownMenu>
)}
</div>
<span className="leading-tight text-sm lg:text-base">{content}</span>
<div
className="leading-tight text-sm lg:text-base content-block"
dangerouslySetInnerHTML={htmlContent}
></div>
</div>
);
};
......
import React, { useState } from "react";
import classNames from "classnames";
import Button from "components/Button";
import { Card, CardActions, CardBody, CardHeadline } from "components/cards";
import ErrorMessage from "components/ErrorMessage";
import MarkdownEditor from "components/mde/MarkdownEditor";
import Modal from "components/modals/Modal";
import { urlRegex } from "utils";
const AnnouncementEditModal = ({
announcement,
......@@ -15,48 +17,107 @@ const AnnouncementEditModal = ({
...props
}) => {
const [text, setText] = useState(announcement.content);
const [noTextError, setNoTextError] = useState(false);
const [link, setLink] = useState(announcement.link);
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);
}
}
};
const onLinkInput = (newLink) => {
setLink(newLink);
if (!!newLink) {
if (newLink.length > 1024) {
setLinkError("Maximální délka URL je 256 znaků.");
} else {
setLinkError(urlRegex.test(newLink) ? null : "Zadejte platnou URL.");
}
}
};
const confirm = (evt) => {
if (!!text) {
onConfirm(text);
evt.preventDefault();
let preventAction = false;
const payload = {
content: text,
};
if (!text) {
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) {
setLinkError("Zadejte platnou URL.");
preventAction = true;
} else {
setNoTextError(true);
payload.link = link;
}
if (preventAction) {
return;
}
onConfirm(payload);
};
return (
<Modal containerClassName="max-w-lg" onRequestClose={onCancel} {...props}>
<Card>
<form onSubmit={confirm}>
<Card className="elevation-21">
<CardBody>
<div className="flex items-center justify-between mb-4">
<CardHeadline>Upravit oznámení</CardHeadline>
<button onClick={onCancel}>
<i className="ico--close"></i>
<button onClick={onCancel} type="button">
<i className="ico--cross"></i>
</button>
</div>
<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"],
["link", "unordered-list", "ordered-list"],
]}
/>
<div
className={classNames("form-field mt-4", {
hidden: announcement.type !== "voting",
"form-field--error": !!linkError,
})}
>
<div className="form-field__wrapper form-field__wrapper--shadowed">
<input
type="text"
className="text-input text-sm text-input--has-addon-l form-field__control"
value={link}
placeholder="URL hlasování"
onChange={(evt) => onLinkInput(evt.target.value)}
/>
<div className="text-input-addon text-input-addon--l order-first">
<i className="ico--link"></i>
</div>
</div>
{!!linkError && (
<div className="form-field__error">{linkError}</div>
)}
</div>
{error && (
<ErrorMessage className="mt-2">
Při editaci došlo k problému: {error}
......@@ -69,7 +130,8 @@ const AnnouncementEditModal = ({
color="blue-300"
className="text-sm"
loading={confirming}
onClick={confirm}
disabled={textError || linkError || confirming}
type="submit"
>
Uložit
</Button>
......@@ -78,11 +140,13 @@ const AnnouncementEditModal = ({
color="red-600"
className="text-sm"
onClick={onCancel}
type="button"
>
Zrušit
</Button>
</CardActions>
</Card>
</form>
</Modal>
);
};
......
......@@ -23,14 +23,27 @@ const AnnouncementList = ({
onSeen(announcement);
};
const getClassName = (idx) => {
if (idx === 0) {
return "pt-4 lg:pt-8";
}
if (idx === items.length - 1) {
return "pb-4 lg:pb-8";
}
return "";
};
return (
<div className={classNames("space-y-px", className)}>
{items.map((item) => (
{items.map((item, idx) => (
<Announcement
className={getClassName(idx)}
key={item.id}
datetime={item.datetime}
type={item.type}
content={item.content}
content={item.contentHtml}
link={item.link}
seen={item.seen}
canRunActions={canRunActions}
......@@ -39,6 +52,11 @@ const AnnouncementList = ({
onSeen={onAnnouncementSeen(item)}
/>
))}
{!items.length && (
<p className="px-8 py-4 leading-snug text-sm md:text-base">
Zatím žádná oznámení.
</p>
)}
</div>
);
};
......
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;
import React from "react";
const AlreadyFinished = () => (
<article className="container container--wide py-8 md:py-16 lg:py-32">
<div className="flex">
<div>
<i className="ico--anchor text-2xl md:text-6xl lg:text-9xl mr-4 lg:mr-8"></i>
</div>
<div>
<h1 className="head-alt-base md:head-alt-md lg:head-alt-xl mb-2">
Jednání už skočilo!
</h1>
<p className="text-xl leading-snug">
Oficiální program již skončil. Těšíme se na viděnou zase příště.
</p>
</div>
</div>
</article>
);
export default AlreadyFinished;