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

Target

Select target project
  • to/cf-online-ui
  • vpfafrin/cf2021
2 results
Select Git revision
Show changes
Showing
with 1486 additions and 87 deletions
......@@ -2,14 +2,26 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Favicons -->
<link rel="apple-touch-icon" href="%REACT_APP_STYLEGUIDE_URL%/images/favicons/favicon-196x196.png">
<link rel="icon" type="image/png" href="%REACT_APP_STYLEGUIDE_URL%/images/favicons/favicon-196x196.png" sizes="196x196">
<meta name="application-name" content="CF2023">
<meta name="msapplication-TileColor" content="#000000">
<meta name="msapplication-TileImage" content="%REACT_APP_STYLEGUIDE_URL%/images/favicons/mstile-144x144.png">
<meta name="msapplication-square70x70logo" content="%REACT_APP_STYLEGUIDE_URL%/images/favicons/mstile-70x70.png">
<meta name="msapplication-square150x150logo" content="%REACT_APP_STYLEGUIDE_URL%/images/favicons/mstile-150x150.png">
<meta name="msapplication-wide310x150logo" content="%REACT_APP_STYLEGUIDE_URL%/images/favicons/mstile-310x150.png">
<meta name="msapplication-square310x310logo" content="%REACT_APP_STYLEGUIDE_URL%/images/favicons/mstile-310x310.png">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<meta property="og:url" content="https://cf2024.online/" />
<meta property="og:type" content="website" />
<meta property="og:title" content="CF 2024 | Pirátská strana" />
<meta property="og:image" content="https://cf2023.online/img/og2024.png" />
<meta property="og:description" content="Oficiální stránka letošního ročníku on-line zasedání Celostátního fóra České pirátské strany, 13. 1. 2024." />
<meta name="description" content="Oficiální stránka letošního ročníku on-line zasedání Celostátního fóra České pirátské strany, 13. 1. 2024." />
<title>CF 2023 | Pirátská strana</title>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
......@@ -24,7 +36,8 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<link rel="stylesheet" href="%REACT_APP_STYLEGUIDE_URL%/css/styles.css" />
<!--<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>-->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
......@@ -40,4 +53,19 @@
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
<!-- Matomo -->
<script type="text/javascript">
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
(function() {
var u="//matomo.pirati.cz/";
_paq.push(['setTrackerUrl', u+'matomo.php']);
_paq.push(['setSiteId', '%REACT_APP_MATOMO_ID%']);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
})();
</script>
<!-- End Matomo Code -->
</html>
public/logo192.png

5.22 KiB

public/logo512.png

9.44 KiB

{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "CF2023",
"name": "Celostátní fórum 2023",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"src": "https://styleguide.pir-test.eu/latest/images/favicons/favicon-32x32.png",
"sizes": "32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
"src": "https://styleguide.pir-test.eu/latest/images/favicons/favicon-96x96.png",
"sizes": "96x96 64x64",
"type": "image/x-icon"
},
{
"src": "logo512.png",
"src": "https://styleguide.pir-test.eu/latest/images/favicons/favicon-196x196.png",
"type": "image/png",
"sizes": "512x512"
"sizes": "192x192 196x196"
}
],
"start_url": ".",
......
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;
import React, { Suspense, useEffect } from "react";
import { Helmet, HelmetProvider } from "react-helmet-async";
import ReactHintFactory from "react-hint";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import { ReactKeycloakProvider as KeycloakProvider } from "@react-keycloak/web";
import { extraErrorDataIntegration } from "@sentry/integrations";
import * as Sentry from "@sentry/react";
import { browserTracingIntegration } from "@sentry/browser"
import { loadAnnouncements } from "actions/announcements";
import { loadConfig } from "actions/global-info";
import { loadPosts } from "actions/posts";
import { loadProgram } from "actions/program";
import { loadMe, refreshAccessToken } from "actions/users";
import { initializeWSChannel } from "actions/ws";
import Footer from "components/Footer";
import Navbar from "components/Navbar";
import About from "pages/About";
import Home from "pages/Home";
import NotFound from "pages/NotFound";
import Program from "pages/Program";
import Protocol from "pages/Protocol";
import { AuthStore, PostStore } from "stores";
import { updateWindowPosts } from "utils";
import keycloak from "./keycloak";
/**
* If configured, set up Sentry client that reports uncaught errors down to
* https://sentry.io.
*/
if (process.env.REACT_APP_SENTRY_DSN) {
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
tracesSampleRate: 0.1,
integrations: [extraErrorDataIntegration(), browserTracingIntegration()],
});
}
const ReactHint = ReactHintFactory(React);
const onKeycloakEvent = async (event) => {
if (event === "onTokenExpired") {
console.warn("[auth] access token expired, attempting refresh");
refreshAccessToken();
}
if (["onAuthRefreshSuccess", "onAuthSuccess"].includes(event)) {
Sentry.setUser(keycloak.tokenParsed);
const kcRoles = keycloak.tokenParsed.roles || [];
let role = null;
if (kcRoles.includes("chairman")) {
role = "chairman";
} else if (kcRoles.includes("member")) {
role = "member";
} else {
role = "regp";
}
AuthStore.update((state) => {
state.isAuthenticated = true;
state.user = {
name: keycloak.tokenParsed.name,
username: keycloak.tokenParsed.preferred_username,
role,
accessToken: keycloak.token,
};
});
// Once base user details has been stored, load me details from API.
loadMe.run();
PostStore.update((state) => {
// Only display proposals verified by chairman to other users.
state.filters.showPendingProposals = role === "chairman";
updateWindowPosts(state);
});
}
};
const LoadingComponent = (
<div className="h-screen w-screen flex justify-center items-center">
<div className="text-center">
<div className="flex flex-col md:flex-row items-center space-x-4 text-center mb-2">
<img
src={`${process.env.REACT_APP_STYLEGUIDE_URL}/images/logo-round-black.svg`}
className="w-16 mb-2"
alt="Pirátská strana"
/>
<h1 className="head-alt-md md:head-alt-lg">Celostátní fórum 2024</h1>
</div>
<p className="text-center head-xs md:head-base">Načítám aplikaci ...</p>
</div>
</div>
);
const BaseApp = () => {
useEffect(() => {
initializeWSChannel.run();
}, []);
return (
<HelmetProvider>
<Router>
<Helmet>
<title>CF 2024 | Pirátská strana</title>
<meta
name="description"
content="Oficiální stránka letošního ročníku on-line zasedání Celostátního fóra České pirátské strany, 13. 1. 2024."
/>
<meta property="og:title" content="CF 2024 | Pirátská strana" />
<meta
property="og:description"
content="Oficiální stránka letošního ročníku on-line zasedání Celostátního fóra České pirátské strany, 13. 1. 2024."
/>
</Helmet>
<Navbar />
<Routes>
<Route exact path="/" element={<Home />} />
<Route exact path="/program" element={<Program />} />
<Route exact path="/protocol" element={<Protocol />} />
<Route exact path="/about" element={<About />} />
<Route element={NotFound} />
</Routes>
<Footer />
</Router>
<ReactHint autoPosition events attribute="data-tip" className="tooltip" />
</HelmetProvider>
);
};
const ConfiguredApp = () => {
loadConfig.read();
loadProgram.read();
loadAnnouncements.read();
loadPosts.read();
return (
<Suspense fallback={LoadingComponent}>
<BaseApp />
</Suspense>
);
};
const AuthenticatedApp = () => {
const keycloakInitConfig = {
onLoad: "check-sso",
// Necessary to prevent Keycloak cookie issues:
// @see: https://stackoverflow.com/a/63588334/303184
checkLoginIframe: false,
};
return (
<>
<KeycloakProvider
authClient={keycloak}
initConfig={keycloakInitConfig}
LoadingComponent={LoadingComponent}
onEvent={onKeycloakEvent}
autoRefreshToken={false}
>
<Suspense fallback={LoadingComponent}>
<ConfiguredApp />
</Suspense>
</KeycloakProvider>
</>
);
};
const ErrorBoundaryFallback = ({ error }) => {
return (
<div className="h-screen w-screen flex justify-center items-center">
<div className="text-center">
<h1 className="head-alt-xl text-red-600 mb-4">
V aplikaci došlo k chybě :(
</h1>
<p className="text-lg leading-normal">
Naši vývojáři o tom již byli informování a opraví to co nejdříve.
<br />
Omlouváme se za tuto nepříjemnost.
</p>
<a href="/" className="btn mt-8">
<div className="btn__body">Načíst znovu</div>
</a>
</div>
</div>
);
};
const App = Sentry.withProfiler(() => {
return (
<Sentry.ErrorBoundary fallback={ErrorBoundaryFallback} showDialog>
<AuthenticatedApp />
</Sentry.ErrorBoundary>
);
});
export default App;
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
import React from "react";
import { render, screen } from "@testing-library/react";
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
import App from "./App";
test("renders learn react link", () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { AnnouncementStore } from "stores";
import {
announcementTypeMappingRev,
createSeenWriter,
parseRawAnnouncement,
seenAnnouncementsLSKey,
syncAnnoucementItemIds,
} from "utils";
export const loadAnnouncements = createAsyncAction(
async () => {
try {
const resp = await fetchApi("/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 = keyBy(announcements, property("id"));
syncAnnoucementItemIds(state);
});
}
},
},
);
/**
* Add new announcement.
*/
export const addAnnouncement = createAsyncAction(
async ({ content, link, type }) => {
try {
const body = JSON.stringify({
content,
link,
type: announcementTypeMappingRev[type],
});
const resp = await fetchApi("/announcements", {
method: "POST",
body,
expectedStatus: 201,
});
const data = await resp.json();
return successResult(data.data);
} catch (err) {
return errorResult([], err.toString());
}
},
);
/**
* Delete existing announcement.
*/
export const deleteAnnouncement = createAsyncAction(
/**
*
* @param {CF2021.Announcement} item
*/
async (item) => {
try {
await fetchApi(`/announcements/${item.id}`, {
method: "DELETE",
expectedStatus: 204,
});
return successResult({ item });
} catch (err) {
return errorResult([], err.toString());
}
},
);
/**
* Update an announcement.
*/
export const updateAnnouncement = createAsyncAction(
/**
*
* @param {CF2021.Announcement} item
* @param {Object} payload
*/
async ({ item, payload }) => {
try {
const body = JSON.stringify(payload);
await fetchApi(`/announcements/${item.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult({ item, payload });
} catch (err) {
return errorResult([], err.toString());
}
},
);
const { markSeen: storeSeen } = createSeenWriter(seenAnnouncementsLSKey);
/**
* Mark down user saw this post already.
* @param {CF2021.Post} post
*/
export const markSeen = (post) => {
storeSeen(post.id);
AnnouncementStore.update((state) => {
state.items[post.id].seen = true;
});
};
import isArray from "lodash/isArray";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { GlobalInfoStore } from "stores";
export const loadConfig = createAsyncAction(
async () => {
try {
const resp = await fetchApi("/config");
const payload = await resp.json();
if (!isArray(payload)) {
return errorResult([], "Unexpected response format");
}
return successResult(payload);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
GlobalInfoStore.update((state) => {
result.payload.forEach((rawConfigItem) => {
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);
});
}
},
},
);
import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { AuthStore, PostStore } from "stores";
import {
createSeenWriter,
filterPosts,
parseRawPost,
postsStateMappingRev,
postsTypeMappingRev,
seenPostsLSKey,
} from "utils";
export const loadPosts = createAsyncAction(
async () => {
try {
const resp = await fetchApi("/posts", { expectedStatus: 200 });
const data = await resp.json();
return successResult(data.data);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error && result.payload) {
const posts = result.payload.map(parseRawPost);
PostStore.update((state) => {
const filteredPosts = filterPosts(state.filters, posts);
state.items = keyBy(posts, property("id"));
state.itemCount = state.items.length;
state.window = {
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
};
});
}
},
},
);
export const like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetchApi(`/posts/${post.id}/like`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "like"
? "like"
: "none";
});
}
},
},
);
export const dislike = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetchApi(`/posts/${post.id}/dislike`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "dislike"
? "dislike"
: "none";
});
}
},
},
);
/**
* Add new discussion post.
*/
export const addPost = createAsyncAction(async ({ content }) => {
try {
const body = JSON.stringify({
content,
type: postsTypeMappingRev["post"],
});
await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
});
/**
* Add new proposal.
*/
export const addProposal = createAsyncAction(async ({ content }) => {
try {
const body = JSON.stringify({
content,
type: postsTypeMappingRev["procedure-proposal"],
});
await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
});
/**
* Hide existing post.
*/
export const hide = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetchApi(`/posts/${post.id}`, {
method: "DELETE",
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
);
/**
* Edit post content.
*/
export const edit = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async ({ post, newContent }) => {
try {
const body = JSON.stringify({
content: newContent,
});
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
{
shortCircuitHook: ({ args }) => {
const { user } = AuthStore.getRawState();
if (!user) {
return errorResult();
}
if (user && user.isBanned) {
return errorResult();
}
return false;
},
},
);
/**
* Archive post.
*/
export const archive = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
const body = JSON.stringify({
is_archived: true,
});
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
);
/**
*
* @param {CF2021.ProposalPost} proposal
* @param {CF2021.ProposalPostState} state
*/
const updateProposalState = async (proposal, state, additionalPayload) => {
const body = JSON.stringify({
state: postsStateMappingRev[state],
...(additionalPayload || {}),
});
await fetchApi(`/posts/${proposal.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(proposal);
};
/**
* Announce procedure proposal.
*/
export const announceProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
(proposal) => {
return updateProposalState(proposal, "announced");
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
return errorResult();
}
if (args.state !== "pending") {
return errorResult();
}
return false;
},
},
);
/**
* Announce procedure proposal.
*/
export const acceptProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
({ proposal, archive }) => {
return updateProposalState(proposal, "accepted", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
},
);
/**
* Reject procedure proposal.
*/
export const rejectProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
},
);
/**
* Reject procedure proposal.
*/
export const rejectProposalByChairman = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected-by-chairman", {
is_archived: archive,
});
},
{
shortCircuitHook: ({ args }) => {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (!["pending", "announced"].includes(args.proposal.state)) {
return errorResult();
}
return false;
},
},
);
const { markSeen: storeSeen } = createSeenWriter(seenPostsLSKey);
/**
* Mark down user saw this post already.
* @param {CF2021.Post} post
*/
export const markSeen = (post) => {
storeSeen(post.id);
PostStore.update((state) => {
state.items[post.id].seen = true;
});
};
import { parse } from "date-fns";
import keyBy from "lodash/keyBy";
import pick from "lodash/pick";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { ProgramStore } from "stores";
import { loadPosts } from "./posts";
export const loadProgram = createAsyncAction(
async () => {
try {
const resp = await fetchApi("/program");
const mappings = await resp.json();
return successResult(mappings);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
const entries = result.payload
.map(
/**
*
* @param {any} entry
* @returns {CF2021.ProgramScheduleEntry}
*/
(entry) => {
return {
...pick(entry, [
"id",
"number",
"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(),
),
expectedFinishAt: entry.expected_finish_at
? parse(
entry.expected_finish_at,
"yyyy-MM-dd HH:mm:ss",
new Date(),
)
: undefined,
};
},
)
.sort((a, b) => a.expectedStartAt - b.expectedStartAt);
const currentEntry = result.payload.find((entry) => entry.is_live);
ProgramStore.update((state) => {
state.items = keyBy(entries, property("id"));
state.scheduleIds = entries.map((entry) => entry.id);
if (currentEntry) {
state.currentId = currentEntry.id;
}
});
}
},
},
);
/**
* Rename program point.
*/
export const renameProgramPoint = createAsyncAction(
async ({ programEntry, newTitle }) => {
try {
const body = JSON.stringify({
title: newTitle,
});
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
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(
/**
*
* @param {CF2021.ProgramScheduleEntry} programEntry
*/
async (programEntry) => {
try {
const body = JSON.stringify({
is_live: false,
});
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
ProgramStore.update((state) => {
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 fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
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 });
}
},
},
);
/**
* Open discussion.
*/
export const openDiscussion = createAsyncAction(
/**
*
* @param {CF2021.ProgramScheduleEntry} programEntry
*/
async (programEntry) => {
try {
const body = JSON.stringify({
discussion_opened: true,
});
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
ProgramStore.update((state) => {
if (state.items[result.payload.id]) {
state.items[result.payload.id].discussionOpened = true;
}
});
}
},
},
);
/**
* Close discussion.
*/
export const closeDiscussion = createAsyncAction(
async (programEntry) => {
try {
const body = JSON.stringify({
discussion_opened: false,
});
await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
ProgramStore.update((state) => {
if (state.items[result.payload.id]) {
state.items[result.payload.id].discussionOpened = false;
}
});
}
},
},
);
import * as Sentry from "@sentry/react";
import { createAsyncAction, errorResult, successResult } from "pullstate";
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 () => {
try {
const response = await fetchApi(`/users/me`, {
method: "GET",
expectedStatus: 200,
});
const data = await response.json();
return successResult(data);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
AuthStore.update((state) => {
if (state.user) {
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(
/**
* @param {number} userId
*/
async (user) => {
try {
await fetchApi(`/users/${user.id}/ban`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(user);
} catch (err) {
return errorResult([], err.toString());
}
},
);
export const unban = createAsyncAction(
/**
* @param {number} userId
*/
async (user) => {
try {
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";
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.
await Promise.all([
loadProgram.run({}, { respectCache: false }),
loadAnnouncements.run({}, { respectCache: false }),
loadPosts.run({}, { respectCache: false }),
]);
// Once loaded, start processing the messages.
worker.start();
return true;
},
});
return successResult(wsChannel);
} catch (err) {
return errorResult([], err.toString());
}
});
import { AuthStore } from "./stores";
export const fetchApi = async (
url,
{ headers = {}, expectedStatus = 200, method = "GET", body = null } = {},
) => {
const { isAuthenticated, user } = AuthStore.getRawState();
if (isAuthenticated) {
headers.Authorization = "Bearer " + user.accessToken;
}
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(process.env.REACT_APP_API_BASE_URL + url, {
body,
method,
headers,
redirect: "follow",
});
if (!!expectedStatus && response.status !== expectedStatus) {
throw new Error(`Unexpected status code ${response.status}`);
}
return response;
};
import React from "react";
import { NavLink } from "react-router-dom";
import classNames from "classnames";
const Button = ({
className,
bodyClassName,
icon,
color = "black",
hoverActive = true,
fullwidth = false,
loading = false,
children,
routerTo,
bodyProps = {},
...props
}) => {
const btnClass = classNames(
"btn",
`btn--${color}`,
{
"btn--icon": !!icon,
"btn--hoveractive": hoverActive,
"btn--fullwidth md:btn--autowidth": fullwidth,
"btn--loading": loading,
},
className
);
const bodyClass = classNames("btn__body", bodyClassName);
const inner = (
<div className="btn__body-wrap">
<div className={bodyClass} {...bodyProps}>
{children}
</div>
{!!icon && (
<div className="btn__icon">
<i className={icon}></i>
</div>
)}
</div>
);
if (routerTo) {
return (
<NavLink to={routerTo} className={btnClass} {...props}>
{inner}
</NavLink>
);
}
return (
<button className={btnClass} {...props}>
{inner}
</button>
);
};
export default React.memo(Button);
import React from "react";
import classNames from "classnames";
const Chip = ({
className,
color = "grey-125",
condensed,
hoveractive = false,
children,
...props
}) => {
const chipClass = classNames(
"chip",
{
"chip--condensed": !!condensed,
"chip--hoveractive": !!hoveractive,
},
`chip--${color}`,
className
);
return (
<span className={chipClass} {...props}>
{children}
</span>
);
};
export default React.memo(Chip);
import React from "react";
import classNames from "classnames";
const Dropdown = ({ value, options, onChange, className }) => {
const onSelectChanged = (evt) => {
onChange(evt.target.value);
};
return (
<span
className={classNames(
"chip chip--grey-125 chip--select chip--hoveractive",
className
)}
>
<select onChange={onSelectChanged} value={value}>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.title}
</option>
))}
</select>
<span className="chip__icon ico--chevron-down"></span>
</span>
);
};
export default React.memo(Dropdown);
import React from "react";
import classNames from "classnames";
const ErrorMessage = ({ className, children }) => {
return (
<div className={classNames("text-red-600 font-bold", className)}>
{children}
</div>
);
};
export default React.memo(ErrorMessage);
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">
<section className="footer__brand">
<img
src={`${process.env.REACT_APP_STYLEGUIDE_URL}/images/logo-full-white.svg`}
alt=""
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, 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-2 gap-4">
<div className="pt-8 pb-4 lg:py-0">
<div className="footer-collapsible">
<span
className={classNames(
"text-xl uppercase text-white footer-collapsible__toggle",
{
"footer-collapsible__toggle--open": showCfMenu,
}
)}
onClick={() => setShowCfMenu(!showCfMenu)}
>
CF 2024
</span>{" "}
<div className={showCfMenu || isLg ? "" : "hidden"}>
<ul className="mt-6 space-y-2 text-grey-200">
<li>
<NavLink to="/">Přímý přenos</NavLink>
</li>
<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={classNames(
"text-xl uppercase text-white footer-collapsible__toggle",
{
"footer-collapsible__toggle--open": showOtherMenu,
}
)}
onClick={() => setShowOtherMenu(!showOtherMenu)}
>
Otevřenost
</span>{" "}
<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>
</li>{" "}
<li>
<a href="https://smlouvy.pirati.cz">Registr smluv</a>
</li>{" "}
<li>
<a href="https://wiki.pirati.cz/fo/otevrene_ucetnictvi">
Otevřené účetnictví
</a>
</li>
</ul>
</div>
</div>
</div>
</section>
<section className="footer__social lg:text-right">
<div className="flex flex-col md:flex-row lg:flex-col lg:items-end space-y-2 md:space-y-0 md:space-x-2 lg:space-x-0 lg:space-y-2">
<a
href="https://dary.pirati.cz"
rel="noopener noreferrer"
target="_blank"
className="btn btn--icon btn--cyan-200 btn--hoveractive text-lg btn--fullwidth sm:btn--autowidth"
>
<div className="btn__body-wrap">
<div className="btn__body ">Přispěj</div>{" "}
<div className="btn__icon ">
<i className="ico--pig"></i>
</div>
</div>
</a>{" "}
<a
href="https://nalodeni.pirati.cz"
rel="noopener noreferrer"
target="_blank"
className="btn btn--icon btn--blue-300 btn--hoveractive text-lg btn--fullwidth sm:btn--autowidth"
>
<div className="btn__body-wrap">
<div className="btn__body ">Naloď se</div>{" "}
<div className="btn__icon ">
<i className="ico--anchor"></i>
</div>
</div>
</a>
</div>
</section>
</div>
</footer>
);
};
export default Footer;