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
No results found
Select Git revision
  • master
1 result
Show changes

Commits on Source 159

59 additional commits have been omitted to prevent performance issues.
93 files
+ 25468
14026
Compare changes
  • Side-by-side
  • Inline

Files

+4 −1
Original line number Diff line number Diff line
REACT_APP_STYLEGUIDE_URL=http://localhost:3001/
REACT_APP_STYLEGUIDE_URL=https://styleguide.pirati.cz/2.11.0
REACT_APP_API_BASE_URL=https://cf2024.online/api
REACT_APP_MATOMO_ID=135
REACT_APP_SENTRY_DSN=https://aa80453ff4d54b9a9c1b49e79060498a@sentry.pirati.cz/14

.env.development

0 → 100644
+1 −0
Original line number Diff line number Diff line
REACT_APP_STYLEGUIDE_URL=http://localhost:3001
+8 −8
Original line number Diff line number Diff line
image: node:buster
image: node:18.20.4-bullseye

variables:
  SITE_NAME: cf2021.pirati.cz
  SITE_NAME: cf2024.online

  ARTIFACTS_PATH: build
  REACT_APP_STYLEGUIDE_URL: https://styleguide.pir-test.eu/latest
  API_URL: https://cf2021.pirati.cz/api
  REACT_APP_API_BASE_URL: /api
  WEBHOOK_URL: https://ha-web.pirati.cz

before_script:
@@ -13,11 +13,11 @@ before_script:
build:
  stage: build
  script:
    - npm install
    - npm install --legacy-peer-deps
    - npm run build
  after_script:
    - echo "{\"job_token\":\"$CI_JOB_TOKEN\", \"name\":\"$SITE_NAME\" }" > request.json
    - "curl -H 'Content-Type: application/json' -X POST -d @request.json $WEBHOOK_URL"
    - echo "{\"name\":\"$SITE_NAME\" }" > request.json
    - "curl -k -H 'Content-Type: application/json' -X POST -d @request.json $WEBHOOK_URL"
  artifacts:
    expire_in: 30 min
    paths:
+1 −1
Original line number Diff line number Diff line
14.13
 No newline at end of file
18.20.4
+7 −6
Original line number Diff line number Diff line
FROM node:14.13-alpine3.12 as build
FROM node:18.20.4-alpine AS build

ARG BUILD_REACT_APP_STYLEGUIDE_URL

ENV REACT_APP_STYLEGUIDE_URL=${BUILD_REACT_APP_STYLEGUIDE_URL}
ENV REACT_APP_STYLEGUIDE_URL="https://styleguide.pirati.cz/2.12.1"
ENV REACT_APP_API_BASE_URL=https://cf2024.online/api
ENV REACT_APP_MATOMO_ID=135
ENV REACT_APP_SENTRY_DSN=https://aa80453ff4d54b9a9c1b49e79060498a@sentry.pirati.cz/14

RUN mkdir -p /home/node/cf2021

@@ -10,7 +11,7 @@ WORKDIR /home/node/cf2021

COPY package*.json ./

RUN npm ci
RUN npm ci --legacy-peer-deps

COPY . .

@@ -18,7 +19,7 @@ RUN npm run build

# ---

FROM nginx:alpine as production
FROM nginx:alpine AS production

COPY --from=build /home/node/cf2021/build /usr/share/nginx/html/
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
Original line number Diff line number Diff line
FROM node:14.13-alpine3.12
FROM node:18.20.4-alpine

#  libs for development -- most of theme needed for canvas support in tests
RUN apk add --no-cache \
@@ -17,7 +17,7 @@ WORKDIR /app/cf2021

COPY package*.json ./

RUN npm ci
RUN npm ci --legacy-peer-deps

COPY . .

compose.yaml

0 → 100644
+6 −0
Original line number Diff line number Diff line
services:
  app:
    image: cf-online
    ports:
      - "3000:80"

eslint.config.mjs

0 → 100644
+67 −0
Original line number Diff line number Diff line
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import jestDom from "eslint-plugin-jest-dom";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import testingLibrary from "eslint-plugin-testing-library";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
  baseDirectory: __dirname,
  recommendedConfig: js.configs.recommended,
  allConfig: js.configs.all,
});

const eslintConfig = [
  ...fixupConfigRules(
    compat.extends(
      "react-app",
      "plugin:prettier/recommended",
      "plugin:testing-library/dom",
      "plugin:jest-dom/recommended",
    ),
  ),
  {
    plugins: {
      "simple-import-sort": simpleImportSort,
      "testing-library": fixupPluginRules(testingLibrary),
      "jest-dom": fixupPluginRules(jestDom),
    },

    rules: {
      "sort-imports": "off",
      "prettier/prettier": "warn",
      "react/no-unknown-property": [1],

      "simple-import-sort/imports": [
        "warn",
        {
          groups: [
            ["^react", "^@?\\w"],
            [
              "^(api|actions|config|hooks|components|containers|pages|utils|stores|keycloak|markdown|ws)(/.*|$)",
            ],
            ["^(test-utils)(/.*|$)"],
            ["^\\u0000"],
            ["^\\.\\.(?!/?$)", "^\\.\\./?$"],
            ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
            ["^.+\\.s?css$"],
          ],
        },
      ],

      "testing-library/await-async-queries": "error",
      "testing-library/no-await-sync-queries": "error",
      "testing-library/no-debugging-utils": "warn",
      "jest-dom/prefer-checked": "error",
      "jest-dom/prefer-enabled-disabled": "error",
      "jest-dom/prefer-required": "error",
      "jest-dom/prefer-to-have-attribute": "error",
    },
  },
];

export default eslintConfig;
+19982 −12806

File changed.

Preview size limit exceeded, changes collapsed.

+51 −89
Original line number Diff line number Diff line
@@ -3,20 +3,35 @@
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@react-keycloak/web": "^2.1.4",
    "@sentry/react": "^5.23.0",
    "classnames": "^2.2.6",
    "date-fns": "^2.16.1",
    "immer": "^7.0.15",
    "keycloak-js": "^10.0.2",
    "lodash": "^4.17.20",
    "pullstate": "^1.20.4",
    "react": "^16.13.1",
    "react-device-detect": "^1.13.1",
    "react-dom": "^16.13.1",
    "react-modal": "^3.12.1",
    "react-router-dom": "^5.2.0",
    "react-scripts": "3.4.3"
    "@react-keycloak/web": "^3.4.0",
    "@rooks/use-interval": "^4.11.2",
    "@rooks/use-outside-click": "^4.11.2",
    "@rooks/use-timeout": "^4.11.2",
    "@rooks/use-window-size": "^4.11.2",
    "@sentry/integrations": "^7.119.2",
    "@sentry/react": "^8.34.0",
    "@sentry/tracing": "^7.119.2",
    "ajv": "^8.17.1",
    "classnames": "^2.5.1",
    "crypto-js": "^4.2.0",
    "date-fns": "^4.1.0",
    "keycloak-js": "^26.0.0",
    "lodash": "^4.17.21",
    "pullstate": "^1.25.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-helmet-async": "^2.0.5",
    "react-hint": "^3.2.1",
    "react-intersection-observer": "^9.13.1",
    "react-joyride": "^2.9.2",
    "react-mde": "^11.5.0",
    "react-modal": "^3.16.1",
    "react-player": "^2.16.0",
    "react-router-dom": "^6.27.0",
    "react-scripts": "^5.0.1",
    "showdown": "^2.1.0",
    "wait-queue": "^1.1.4",
    "xss": "^1.0.15"
  },
  "scripts": {
    "start": "react-scripts start",
@@ -24,66 +39,8 @@
    "test": "react-scripts test --env=jsdom-fourteen",
    "eject": "react-scripts eject",
    "lint": "eslint --cache 'src/**/*.{js,jsx}'",
    "lint:fix": "eslint --cache --fix 'src/**/*.{js,jsx}'"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "plugin:prettier/recommended",
      "plugin:testing-library/recommended",
      "plugin:jest-dom/recommended"
    ],
    "plugins": [
      "simple-import-sort",
      "testing-library",
      "jest-dom"
    ],
    "rules": {
      "sort-imports": "off",
      "prettier/prettier": "warn",
      "react/no-unknown-property": [
        1
      ],
      "simple-import-sort/sort": [
        "warn",
        {
          "groups": [
            [
              "^react",
              "^@?\\w"
            ],
            [
              "^(actions|components|containers|pages|utils|stores|keycloak)(/.*|$)"
            ],
            [
              "^(test-utils)(/.*|$)"
            ],
            [
              "^\\u0000"
            ],
            [
              "^\\.\\.(?!/?$)",
              "^\\.\\./?$"
            ],
            [
              "^\\./(?=.*/)(?!/?$)",
              "^\\.(?!/?$)",
              "^\\./?$"
            ],
            [
              "^.+\\.s?css$"
            ]
          ]
        }
      ],
      "testing-library/await-async-query": "error",
      "testing-library/no-await-sync-query": "error",
      "testing-library/no-debug": "warn",
      "jest-dom/prefer-checked": "error",
      "jest-dom/prefer-enabled-disabled": "error",
      "jest-dom/prefer-required": "error",
      "jest-dom/prefer-to-have-attribute": "error"
    }
    "lint:fix": "eslint --cache --fix 'src/**/*.{js,jsx}'",
    "analyze": "source-map-explorer 'build/static/js/*.js'"
  },
  "browserslist": {
    "production": [
@@ -98,22 +55,27 @@
    ]
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^4.0.0",
    "@testing-library/react": "^10.4.4",
    "@testing-library/react-hooks": "^3.4.2",
    "@testing-library/user-event": "^12.1.7",
    "@eslint/compat": "^1.2.0",
    "@eslint/eslintrc": "^3.1.0",
    "@eslint/js": "^9.12.0",
    "@testing-library/jest-dom": "^6.5.0",
    "@testing-library/react": "^16.0.1",
    "@testing-library/react-hooks": "^8.0.1",
    "@testing-library/user-event": "^14.5.2",
    "babel-core": "^6.26.3",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.8.0",
    "eslint-config-airbnb": "^18.2.0",
    "eslint-config-prettier": "^6.11.0",
    "eslint-plugin-import": "^2.22.0",
    "eslint-plugin-jest-dom": "^3.6.3",
    "eslint-plugin-jsx-a11y": "^6.3.1",
    "eslint-plugin-prettier": "^3.1.4",
    "eslint-plugin-react": "^7.20.6",
    "eslint-plugin-simple-import-sort": "^5.0.3",
    "eslint-plugin-testing-library": "^3.8.0",
    "prettier": "^2.1.1"
    "eslint": "^9.12.0",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-jest-dom": "^5.4.0",
    "eslint-plugin-jsx-a11y": "^6.10.0",
    "eslint-plugin-prettier": "^5.2.1",
    "eslint-plugin-react": "^7.37.1",
    "eslint-plugin-simple-import-sort": "^12.1.1",
    "eslint-plugin-testing-library": "^6.3.0",
    "prettier": "^3.3.3",
    "source-map-explorer": "^2.5.3",
    "typescript": "^5.6.3"
  }
}
+37 −10
Original line number Diff line number Diff line
@@ -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/
@@ -25,11 +37,11 @@
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <link rel="stylesheet" href="%REACT_APP_STYLEGUIDE_URL%/css/styles.css" />
    <title>React App</title>
    <!--<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>-->
  </head>
  <body class="h-screen">
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root" class="h-screen h-full w-full"></div>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.
@@ -41,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>
Original line number Diff line number Diff line
{
  "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": ".",
+104 −25
Original line number Diff line number Diff line
import React, { Suspense } from "react";
import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { KeycloakProvider } from "@react-keycloak/web";
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 { loadGroupMappings } from "actions/misc";
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 { AuthStore } from "stores";
import Protocol from "pages/Protocol";
import { AuthStore, PostStore } from "stores";
import { updateWindowPosts } from "utils";

import keycloak from "./keycloak";

@@ -20,49 +33,114 @@ if (process.env.REACT_APP_SENTRY_DSN) {
  Sentry.init({
    dsn: process.env.REACT_APP_SENTRY_DSN,
    tracesSampleRate: 0.1,
    integrations: [extraErrorDataIntegration(), browserTracingIntegration()],
  });
}

const onKeycloakEvent = (event) => {
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,
        groups: keycloak.tokenParsed.groups,
        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-20 mb-2 inline-block"
          className="w-16 mb-2"
          alt="Pirátská strana"
        />
      <h1 className="head-heavy-base mb-2">Celostátní fórum 2021</h1>
      <p>Načítám aplikaci ...</p>
        <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 = () => {
  loadGroupMappings.read();
  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 />
      <Switch>
        <Route exact path="/" children={<Home />} />
        <Route exact path="/program" children={<Program />} />
      </Switch>
        <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>
  );
};

@@ -77,13 +155,14 @@ const AuthenticatedApp = () => {
  return (
    <>
      <KeycloakProvider
        keycloak={keycloak}
        authClient={keycloak}
        initConfig={keycloakInitConfig}
        LoadingComponent={LoadingComponent}
        onEvent={onKeycloakEvent}
        autoRefreshToken={false}
      >
        <Suspense fallback={LoadingComponent}>
          <BaseApp />
          <ConfiguredApp />
        </Suspense>
      </KeycloakProvider>
    </>
+3 −3
Original line number Diff line number Diff line
import React from "react";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";

import App from "./App";

test("renders learn react link", () => {
  const { getByText } = render(<App />);
  const linkElement = getByText(/learn react/i);
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});
Original line number Diff line number Diff line
import { createAsyncAction, successResult } from "pullstate";
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 }) => {
    /** @type {CF2021.Announcement} */
    const payload = {
      id: "999",
      datetime: new Date(),
      type: "announcement",
  async ({ content, link, type }) => {
    try {
      const body = JSON.stringify({
        content,
      seen: true,
    };

    return successResult(payload);
        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());
    }
  },
  {
    postActionHook: ({ result }) => {
      AnnouncementStore.update((state) => {
        state.items.push(result.payload);
);

/**
 * 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;
  });
};
+69 −0
Original line number Diff line number Diff line
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);
        });
      }
    },
  },
);

src/actions/misc.js

deleted100644 → 0
+0 −24
Original line number Diff line number Diff line
import { createAsyncAction, errorResult, successResult } from "pullstate";

import { AuthStore } from "stores";

export const loadGroupMappings = createAsyncAction(
  async () => {
    try {
      const resp = await fetch("https://iapi.pirati.cz/v1/groups");
      const mappings = await resp.json();
      return successResult(mappings);
    } catch (err) {
      return errorResult([], err.toString());
    }
  },
  {
    postActionHook: ({ result }) => {
      if (!result.error) {
        AuthStore.update((state) => {
          state.groupMappings = result.payload;
        });
      }
    },
  }
);
Original line number Diff line number Diff line
import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";

import { PostStore } from "stores";
import { updateWindowPosts } from "utils";
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());
    }
  },
  {
    shortCircuitHook: ({ args }) => {
      if (args.ranking.myVote !== "none") {
        return errorResult();
    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";
        });
      }
    },
  },
);

      return false;
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.likes += 1;
          state.items[result.payload.id].ranking.score += 1;
          state.items[result.payload.id].ranking.myVote = "like";
          state.items[result.payload.id].ranking.myVote =
            state.items[result.payload.id].ranking.myVote !== "dislike"
              ? "dislike"
              : "none";
        });
      }
    },
  },
);

          if (state.filters.sort === "byScore") {
            updateWindowPosts(state);
/**
 * 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());
    }
  },
);

export const dislike = createAsyncAction(
/**
 * Edit post content.
 */
export const edit = createAsyncAction(
  /**
   * @param {CF2021.Post} post
   */
  async (post) => {
    return successResult(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 }) => {
      if (args.ranking.myVote !== "none") {
      const { user } = AuthStore.getRawState();

      if (!user) {
        return errorResult();
      }

      if (user && user.isBanned) {
        return errorResult();
      }

      return false;
    },
    postActionHook: ({ result }) => {
      if (!result.error) {
        PostStore.update((state) => {
          state.items[result.payload.id].ranking.dislikes += 1;
          state.items[result.payload.id].ranking.score -= 1;
          state.items[result.payload.id].ranking.myVote = "dislike";
  },
);

          if (state.filters.sort === "byScore") {
            updateWindowPosts(state);
          }
/**
 * 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());
    }
  },
  }
);

/**
 * Add new discussion post.
 *
 * @param {CF2021.ProposalPost} proposal
 * @param {CF2021.ProposalPostState} state
 */
export const addPost = createAsyncAction(
  async ({ content }) => {
    /** @type {CF2021.DiscussionPost} */
    const payload = {
      id: "999",
      datetime: new Date(),
      author: {
        name: "John Doe",
        group: "KS Pardubický kraj",
      },
      type: "post",
      content,
      ranking: {
        score: 0,
        likes: 0,
        dislikes: 0,
        myVote: "none",
      },
      historyLog: [],
      archived: false,
      hidden: false,
      seen: true,
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);
};
    return successResult(payload);

/**
 * Announce procedure proposal.
 */
export const announceProposal = createAsyncAction(
  /**
   * @param {CF2021.ProposalPost} proposal
   */
  (proposal) => {
    return updateProposalState(proposal, "announced");
  },
  {
    postActionHook: ({ result }) => {
      PostStore.update((state) => {
        state.items[result.payload.id] = result.payload;
        updateWindowPosts(state);
      });
    },
    shortCircuitHook: ({ args }) => {
      if (args.type !== "procedure-proposal") {
        return errorResult();
      }

      if (args.state !== "pending") {
        return errorResult();
      }

      return false;
    },
  },
);

/**
 * Add new proposal.
 * Announce procedure proposal.
 */
export const addProposal = createAsyncAction(
  async ({ content }) => {
    /** @type {CF2021.ProposalPost} */
    const payload = {
      id: "999",
      datetime: new Date(),
      author: {
        name: "John Doe",
        group: "KS Pardubický kraj",
      },
      type: "procedure-proposal",
      state: "pending",
      content,
      ranking: {
        score: 0,
        likes: 0,
        dislikes: 0,
        myVote: "none",
      },
      historyLog: [],
      archived: false,
      hidden: false,
      seen: true,
    };
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;
    },
  },
);

    return successResult(payload);
/**
 * Reject procedure proposal.
 */
export const rejectProposal = createAsyncAction(
  /**
   * @param {CF2021.ProposalPost} proposal
   */
  ({ proposal, archive }) => {
    return updateProposalState(proposal, "rejected", { is_archived: archive });
  },
  {
    postActionHook: ({ result }) => {
      PostStore.update((state) => {
        state.items[result.payload.id] = result.payload;
        updateWindowPosts(state);
    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;
  });
};

src/actions/program.js

0 → 100644
+251 −0
Original line number Diff line number Diff line
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;
          }
        });
      }
    },
  },
);

src/actions/users.js

0 → 100644
+125 −0
Original line number Diff line number Diff line
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);
    });
  }
};

src/actions/ws.js

0 → 100644
+36 −0
Original line number Diff line number Diff line
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());
  }
});

src/api.js

0 → 100644
+29 −0
Original line number Diff line number Diff line
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;
};
Original line number Diff line number Diff line
import React from "react";
import { NavLink } from "react-router-dom";
import classNames from "classnames";

const Button = ({
  className,
  iconWrapperClassName,
  bodyClassName,
  icon,
  color = "black",
  iconChildren = null,
  hoverActive = true,
  fullwidth = false,
  loading = false,
  children,
  routerTo,
  bodyProps = {},
  ...props
}) => {
  const btnClass = classNames(
@@ -19,25 +22,39 @@ const Button = ({
      "btn--icon": !!icon,
      "btn--hoveractive": hoverActive,
      "btn--fullwidth md:btn--autowidth": fullwidth,
      "btn--loading": loading,
    },
    className
  );

  const iconWrapperClass = classNames("btn__icon", iconWrapperClassName);
  const bodyClass = classNames("btn__body", bodyClassName);

  return (
    <button className={btnClass} {...props}>
  const inner = (
    <div className="btn__body-wrap">
        <div className="btn__body">{children}</div>
      <div className={bodyClass} {...bodyProps}>
        {children}
      </div>
      {!!icon && (
          <div className={iconWrapperClass}>
        <div className="btn__icon">
          <i className={icon}></i>
            {iconChildren}
        </div>
      )}
    </div>
  );

  if (routerTo) {
    return (
      <NavLink to={routerTo} className={btnClass} {...props}>
        {inner}
      </NavLink>
    );
  }

  return (
    <button className={btnClass} {...props}>
      {inner}
    </button>
  );
};

export default Button;
export default React.memo(Button);
Original line number Diff line number Diff line
@@ -26,4 +26,4 @@ const Chip = ({
  );
};

export default Chip;
export default React.memo(Chip);
Original line number Diff line number Diff line
@@ -25,4 +25,4 @@ const Dropdown = ({ value, options, onChange, className }) => {
  );
};

export default Dropdown;
export default React.memo(Dropdown);
+12 −0
Original line number Diff line number Diff line
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);
Original line number Diff line number Diff line
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">
        <section className="footer__brand">
          <img
            src="https://www.va-fighters.com/pirati/krajska-sablona/dist/assets/img/logo.svg"
            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, 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>
        <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">
            <button 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 ">Pomoz nám</div>{" "}
                <div className="btn__icon ">
                  <i className="ico--anchor"></i>
                </div>
              </div>
            </button>
        <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>
        </section>
            </div>
      <section className="bg-black py-4 lg:py-12">
        <div className="container container--default">
          <div className="grid gap-4 grid-cols-1 md:grid-cols-2">
            <div className="badge w-full">
              <div className="avatar avatar--sm badge__avatar">
                <img src="http://placeimg.com/100/100/people" alt="Avatar" />
          </div>
              <div className="badge__body">
                <span className="head-heavy-2xs badge__title">
                  Andrej Ramašeuski
                </span>
                <p className="badge__occupation">Kontakt pro dobrovolníky</p>
                <a
                  href="mailto:example@example.com"
                  className="contact-line contact-line--responsive icon-link badge__link"
          <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)}
              >
                  <i className="ico--phone"></i>
                  <span>+420 777 123 123</span>
                </a>
                <a
                  href="mailto:example@example.com"
                  className="contact-line contact-line--responsive icon-link badge__link"
                >
                  <i className="ico--envelope"></i>
                  <span>example@example.com</span>
                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 className="badge w-full">
              <div className="avatar avatar--sm badge__avatar">
                <img src="http://placeimg.com/100/100/people" alt="Avatar" />
          </div>
              <div className="badge__body">
                <span className="head-heavy-2xs badge__title">
                  Andrea Linhartová
                </span>
                <p className="badge__occupation">Kontakt pro média</p>
        </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="mailto:example@example.com"
                  className="contact-line contact-line--responsive icon-link badge__link"
              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"
            >
                  <i className="ico--phone"></i>
                  <span>+420 777 123 123</span>
                </a>
              <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="mailto:example@example.com"
                  className="contact-line contact-line--responsive icon-link badge__link"
              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"
            >
                  <i className="ico--envelope"></i>
                  <span>example@example.com</span>
                </a>
              </div>
              <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>
  );
};
Original line number Diff line number Diff line
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,33 +88,52 @@ 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">
                <li className="navbar-menu__item">
                  <NavLink className="navbar-menu__link" to="/">
                    Přímý přenos
                  </NavLink>
                </li>
                <li className="navbar-menu__item">
                  <NavLink className="navbar-menu__link" to="/program">
                    Program
                  </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
                      src="http://placeimg.com/100/100/people"
                      src={`https://a.pirati.cz/piratar/200/${user.username}.jpg`}
                      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>
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const Thumbs = ({ likes, dislikes, onLike, onDislike, canThumb }) => {
const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => {
  return (
    <div>
      <div className="space-x-2 text-sm flex items-center">
        <button
          className={classNames("text-blue-300 flex items-center space-x-1", {
            "cursor-not-allowed": !canThumb,
          className={classNames("flex items-center space-x-1", {
            "cursor-pointer": !readOnly,
            "cursor-not-allowed": readOnly,
            "text-blue-300": myVote === "like",
            "text-grey-200 ": myVote !== "like",
            "hover:text-blue-300": myVote !== "like" && !readOnly,
          })}
          disabled={!canThumb}
          disabled={readOnly}
          onClick={onLike}
        >
          <span className="font-bold">{likes}</span>
          <i className="ico--thumbs-up"></i>
        </button>
        <button
          className={classNames("text-red-600 flex items-center space-x-1", {
            "cursor-not-allowed": !canThumb,
          className={classNames("flex items-center space-x-1", {
            "cursor-pointer": !readOnly,
            "cursor-not-allowed": readOnly,
            "text-red-600": myVote === "dislike",
            "text-grey-200": myVote !== "dislike",
            "hover:text-red-600": myVote !== "dislike" && !readOnly,
          })}
          disabled={!canThumb}
          disabled={readOnly}
          onClick={onDislike}
        >
          <i className="ico--thumbs-down transform -scale-x-1"></i>
Original line number Diff line number Diff line
import React from "react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import classNames from "classnames";
import { format } from "date-fns";
import { format, isToday } from "date-fns";

import Chip from "components/Chip";
import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu";
@@ -13,12 +14,27 @@ const Announcement = ({
  link,
  relatedPostId,
  seen,
  displayActions = false,
  canRunActions,
  onDelete,
  onEdit,
  onSeen,
}) => {
  const { ref, inView } = useInView({
    threshold: 0.8,
    trackVisibility: true,
    delay: 1500,
    skip: seen,
    triggerOnce: true,
  });

  useEffect(() => {
    if (!seen && inView && onSeen) {
      onSeen();
    }
  });

  const wrapperClassName = classNames(
    "bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3",
    "bg-opacity-50 border-l-2 px-4 py-2 lg:px-8 lg:py-3 transition duration-500",
    {
      "bg-grey-50": !!seen,
      "bg-yellow-100": !seen,
@@ -43,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",
@@ -51,7 +67,7 @@ const Announcement = ({
  }[type];

  const linkLabel =
    type === "voting" ? "Hlasovat v heliosu" : "Zobrazit související příspěvek";
    type === "voting" ? "Hlasovat" : "Zobrazit související příspěvek";

  const showEdit = [
    "suggested-procedure-proposal",
@@ -59,34 +75,56 @@ const Announcement = ({
    "announcement",
  ].includes(type);

  const htmlContent = {
    __html: content,
  };

  return (
    <div className={wrapperClassName}>
    <div className={wrapperClassName} ref={ref}>
      <div className="flex items-center justify-between mb-2">
        <div className="space-x-2 flex items-center">
          <div className="font-bold text-sm">{format(datetime, "H:mm")}</div>
          <div className="font-bold text-sm">
            {format(datetime, isToday(datetime) ? "H:mm" : "dd. MM. H:mm")}
          </div>
          <Chip color={chipColor} condensed>
            {chipLabel}
          </Chip>
          {link && <a href={link}>{linkLabel + "»"}</a>}
          {link && (
            <a
              href={link}
              className={classNames("text-xs font-bold text-" + chipColor)}
              target="_blank"
              rel="noopener noreferrer"
            >
              {linkLabel + " »"}
            </a>
          )}
        </div>
        {displayActions && (
          <DropdownMenu right triggerIconClass="ico--dots-three-horizontal">
        {canRunActions && (
          <DropdownMenu
            right
            className="pl-4"
            triggerIconClass="ico--dots-three-horizontal"
          >
            {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>
  );
};
Original line number Diff line number Diff line
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,
  onCancel,
  onConfirm,
  confirming,
  error,
  ...props
}) => {
  const [text, setText] = useState(announcement.content);
  const [link, setLink] = useState(announcement.link);
  const [textError, setTextError] = useState(null);
  const [linkError, setLinkError] = useState(null);

  const onTextInput = (newText) => {
    setText(newText);

    if (newText !== "") {
      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) => {
    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 {
      payload.link = link;
    }

    if (preventAction) {
      return;
    }

    onConfirm(payload);
  };

  return (
    <Modal containerClassName="max-w-lg" onRequestClose={onCancel} {...props}>
      <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} type="button">
                <i className="ico--cross"></i>
              </button>
            </div>
            <MarkdownEditor
              value={text}
              onChange={onTextInput}
              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}
              </ErrorMessage>
            )}
          </CardBody>
          <CardActions right className="space-x-1">
            <Button
              hoverActive
              color="blue-300"
              className="text-sm"
              loading={confirming}
              disabled={textError || linkError || confirming}
              type="submit"
            >
              Uložit
            </Button>
            <Button
              hoverActive
              color="red-600"
              className="text-sm"
              onClick={onCancel}
              type="button"
            >
              Zrušit
            </Button>
          </CardActions>
        </Card>
      </form>
    </Modal>
  );
};

export default AnnouncementEditModal;
Original line number Diff line number Diff line
@@ -6,33 +6,57 @@ import Announcement from "./Announcement";
const AnnouncementList = ({
  items,
  className,
  displayActions,
  canRunActions,
  onDelete,
  onEdit,
  onSeen,
}) => {
  const buildHandler = (responderFn) => (post) => (evt) => {
  const buildHandler = (responderFn) => (announcement) => (evt) => {
    evt.preventDefault();
    responderFn(post);
    responderFn(announcement);
  };

  const onAnnouncementEdit = buildHandler(onEdit);
  const onAnnouncementDelete = buildHandler(onDelete);

  const onAnnouncementSeen = (announcement) => () => {
    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}
          displayActions={displayActions}
          canRunActions={canRunActions}
          onEdit={onAnnouncementEdit(item)}
          onDelete={onAnnouncementDelete(item)}
          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>
  );
};
+13 −0
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const Card = ({ children, className }, ref) => {
  const cls = classNames("card", className);
  return (
    <div className={cls} ref={ref}>
      {children}
    </div>
  );
};

export default React.forwardRef(Card);
+13 −0
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const CardActions = ({ children, right, className }) => {
  const cls = classNames(
    "card-actions",
    { "card-actions--right": !!right },
    className
  );
  return <div className={cls}>{children}</div>;
};

export default CardActions;
+13 −0
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const CardBody = ({ children, className, ...props }) => {
  const cls = classNames("card__body", className);
  return (
    <div className={cls} {...props}>
      {children}
    </div>
  );
};

export default CardBody;
+7 −0
Original line number Diff line number Diff line
import React from "react";

const CardBodyText = ({ children }) => {
  return <div className="card-body-text">{children}</div>;
};

export default CardBodyText;
+7 −0
Original line number Diff line number Diff line
import React from "react";

const CardHeadline = ({ children }) => {
  return <h1 className="card-headline">{children}</h1>;
};

export default CardHeadline;
+5 −0
Original line number Diff line number Diff line
export { default as Card } from "./Card";
export { default as CardActions } from "./CardActions";
export { default as CardBody } from "./CardBody";
export { default as CardBodyText } from "./CardBodyText";
export { default as CardHeadline } from "./CardHeadline";
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const DropdownButton = ({
  className,
  color = "black",
  hoverActive = true,
  fullwidth = false,
  loading = false,
  disabled = false,
  items,
  children,
  onClick,
  ...props
}) => {
  const btnClass = classNames(
    "btn btn--icon",
    `btn--${color}`,
    {
      "btn--hoveractive": hoverActive,
      "btn--fullwidth md:btn--autowidth": fullwidth,
      "btn--loading": loading,
    },
    className
  );

  const inner = (
    <div className="btn__body-wrap">
      <button className="btn__body" onClick={onClick} disabled={disabled}>
        {children}
      </button>
      <button className="btn__icon dropdown-button">
        <i className="ico--chevron-down"></i>
        <ul className="dropdown-button__choices bg-white text-black whitespace-no-wrap">
          {items}
        </ul>
      </button>
    </div>
  );

  return (
    <div className={btnClass} {...props}>
      {inner}
    </div>
  );
};

export default DropdownButton;
Original line number Diff line number Diff line
import React from "react";

const DropdownButtonItem = ({ onClick, children }) => {
  return (
    <li className="dropdown-button__choice hover:bg-grey-125" onClick={onClick}>
      <span className="block px-4 py-3">{children}</span>
    </li>
  );
};

export default DropdownButtonItem;
Original line number Diff line number Diff line
export { default as DropdownButton } from "./DropdownButton";
export { default as DropdownButtonItem } from "./DropdownButtonItem";
+21 −0
Original line number Diff line number Diff line
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;
+31 −0
Original line number Diff line number Diff line
import React from "react";

import Button from "components/Button";

const BreakInProgress = () => (
  <article className="container container--wide py-8 md:py-16 lg:py-32">
    <div className="flex">
      <div>
        <i className="ico--clock 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">
          Probíhá přestávka ...
        </h1>
        <p className="text-xl leading-snug mb-8">
          Jednání celostátního fóra je momentálně přerušeno. Můžete si ale
          zobrazit program.
        </p>
        <Button
          routerTo="/program"
          className="md:text-lg lg:text-xl"
          hoverActive
        >
          Zobrazit program
        </Button>
      </div>
    </div>
  </article>
);

export default BreakInProgress;
+30 −0
Original line number Diff line number Diff line
import React from "react";
import { format } from "date-fns";

import Button from "components/Button";

const NotYetStarted = ({ startAt }) => (
  <article className="container container--wide py-8 md:py-16 lg:py-32">
    <div className="hidden md:inline-block flag bg-violet-400 text-white head-alt-base mb-4 py-4 px-5">
      Jejda ...
    </div>
    <h1 className="head-alt-base md:head-alt-md lg:head-alt-xl mb-2">
      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>
      {startAt && (
        <span>
          Mělo by být zahájeno <strong>{format(startAt, "d. M. Y")}</strong> v{" "}
          <strong>{format(startAt, "H:mm")}</strong>.{" "}
        </span>
      )}
      <span>Můžete si ale zobrazit program.</span>
    </p>
    <Button routerTo="/program" className="md:text-lg lg:text-xl" hoverActive>
      Zobrazit program
    </Button>
  </article>
);

export default NotYetStarted;
+3 −0
Original line number Diff line number Diff line
export { default as AlreadyFinished } from "./AlreadyFinished";
export { default as BreakInProgress } from "./BreakInProgress";
export { default as NotYetStarted } from "./NotYetStarted";
+102 −0
Original line number Diff line number Diff line
.mde-header {
  background: transparent;
}

.react-mde .invisible {
  display: none;
}

.mde-header {
  border: 0;
  align-items: center;
}

.mde-header .mde-tabs {
  display: inline-flex;
  background-color: #000;
  background-color: rgba(0,0,0,1);
  padding: .25rem;
}

.mde-header .mde-tabs button {
  padding: .25rem 0.25rem;
  font-family: Bebas Neue,Helvetica,Arial,sans-serif;
  font-weight: 400;
  font-size: 1.1rem;
  --text-opacity: 1;
  color: #fff;
  color: rgba(255,255,255,var(--text-opacity));
  text-align: center;
  cursor: pointer;
  border: 0;
  border-radius: 0;
  margin: 0 !important;
  outline: 0;
}

.mde-header {
  /* grey-50 */
  background-color: #f7f7f7;
}

.mde-header .mde-tabs button.selected {
  /* blue-300 */
  background: #027da8;
  color: #fff;
  border: 0;
  border-radius: 0;
  margin: 0;
  outline: 0;
}

.mde-header .mde-tabs button:not(.selected):hover {
  /* grey-500 */
  background: #303132;
}

.mde-header .mde-header-item {
  border: 1px transparent solid;
  transition: border-color 100ms ease-in-out;
}

.mde-header ul.mde-header-group li.mde-header-item {
  margin: 0;
}

.mde-header .mde-header-item:hover {
  /* grey-200 */
  border: 1px #adadad solid;
}

.mde-text {
  font-family: monospace;
}


.mde-header ul.mde-header-group {
  padding: 0;
}

.mde-header ul.mde-header-group:first-of-type {
  padding-left: .5rem;
}

.mde-header ul.mde-header-group + .mde-header-group {
  margin-left: 0;
}

@media (min-width: 992px) {
  .mde-header .mde-tabs button {
    padding: .5rem 1rem;
  }
}

@media (min-width: 1200px) {
  .mde-header ul.mde-header-group {
    padding: 0 0.5rem;
  }

  .mde-header ul.mde-header-group + .mde-header-group {
    margin-left: .5rem;
  }
}
+54 −0
Original line number Diff line number Diff line
import React, { useState } from "react";
import ReactMde from "react-mde";
import classNames from "classnames";

import { markdownConverter } from "markdown";

import "react-mde/lib/styles/css/react-mde-toolbar.css";
import "./MarkdownEditor.css";

const MarkdownEditor = (
  { value, onChange, error, placeholder = "", ...props },
  ref
) => {
  const [selectedTab, setSelectedTab] = useState("write");

  const classes = {
    preview: "p-2 content-block text-input text-sm md:text-base",
    textArea: "p-2 text-input text-sm md:text-base",
  };

  const l18n = {
    write: "Psaní",
    preview: "Náhled",
    uploadingImage: "Nahrávám obrázek",
  };

  const childProps = {
    textArea: {
      placeholder,
    },
  };

  return (
    <div className={classNames("form-field", { "form-field--error": !!error })}>
      <ReactMde
        ref={ref}
        value={value}
        onChange={onChange}
        selectedTab={selectedTab}
        onTabChange={setSelectedTab}
        generateMarkdownPreview={(markdown) =>
          Promise.resolve(markdownConverter.makeHtml(markdown))
        }
        classes={classes}
        l18n={l18n}
        childProps={childProps}
        {...props}
      />
      {error && <div className="form-field__error">{error}</div>}
    </div>
  );
};

export default React.forwardRef(MarkdownEditor);
Original line number Diff line number Diff line
@@ -9,8 +9,13 @@ const CustomModal = ({ children, containerClassName, ...props }) => (
    className="modal__content"
    {...props}
  >
    <div className={classNames("modal__container w-full", containerClassName)}>
      <div className="modal__container-body">{children}</div>
    <div
      className={classNames(
        "modal__container w-full flex items-center justify-center",
        containerClassName
      )}
    >
      <div className="modal__container-body w-full">{children}</div>
    </div>
  </Modal>
);
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@ import React from "react";

import Button from "components/Button";

import Modal from "./Modal";
import ModalWithActions from "./ModalWithActions";

const ModalConfirm = ({
  title,
@@ -11,41 +11,44 @@ const ModalConfirm = ({
  cancelActionLabel = "Zrušit",
  onCancel,
  onConfirm,
  confirming,
  error,
  ...props
}) => {
  return (
    <Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
      <div className="card elevation-21">
        <div className="card__body">
          <div className="flex items-center justify-between mb-4">
            <h1 className="card-headline">{title}</h1>
            <button onClick={onCancel}>
              <i className="ico--close"></i>
            </button>
          </div>
          <p className="card-body-text">{children}</p>
        </div>
        <div className="card-actions card-actions--right space-x-1">
  const actions = (
    <>
      <Button
            hoveractive
        hoverActive
        color="blue-300"
        className="text-sm"
        onClick={onConfirm}
        loading={confirming}
      >
        {yesActionLabel}
      </Button>
      <Button
            hoveractive
            color="red-600"
        hoverActive
        color="grey-125"
        className="text-sm"
            onClick={onConfirm}
        onClick={onCancel}
      >
        {cancelActionLabel}
      </Button>
        </div>
      </div>
    </Modal>
    </>
  );

  return (
    <ModalWithActions
      onClose={onCancel}
      title={title}
      error={error}
      actions={actions}
      containerClassName="max-w-md"
      {...props}
    >
      {children}
    </ModalWithActions>
  );
};

export default ModalConfirm;
export default React.memo(ModalConfirm);
Original line number Diff line number Diff line
import React from "react";

import {
  Card,
  CardActions,
  CardBody,
  CardBodyText,
  CardHeadline,
} from "components/cards";
import ErrorMessage from "components/ErrorMessage";

import Modal from "./Modal";

const ModalConfirm = ({
  title,
  children,
  actions,
  error,
  onClose,
  ...props
}) => {
  return (
    <Modal onRequestClose={onClose} {...props}>
      <Card className="elevation-21">
        <CardBody>
          <div className="flex items-center justify-between mb-4">
            <CardHeadline>{title}</CardHeadline>
            <button
              onClick={onClose}
              type="button"
              data-tip="Zavřít"
              aria-label="Zavřít"
            >
              <i className="ico--cross"></i>
            </button>
          </div>
          <CardBodyText>{children}</CardBodyText>
          {error && (
            <ErrorMessage className="mt-2">
              Při provádění akce došlo k problému: {error}
            </ErrorMessage>
          )}
        </CardBody>
        <CardActions right className="space-x-1">
          {actions}
        </CardActions>
      </Card>
    </Modal>
  );
};

export default React.memo(ModalConfirm);
+15 −0
Original line number Diff line number Diff line
import React from "react";

const Beacon = React.forwardRef(({ onClick }, ref) => (
  <span
    className="relative inline-flex h-8 w-8"
    title="Jsi online"
    ref={ref}
    onClick={onClick}
  >
    <span className="animate-ping absolute inline-flex h-full w-full rounded-full opacity-75 bg-red-600"></span>
    <span className="inline-flex rounded-full w-8 h-8 bg-red-600"></span>
  </span>
));

export default Beacon;
+149 −0
Original line number Diff line number Diff line
import React from "react";

// import Chip from "components/Chip";

export { default as Beacon } from "./Beacon";

export const steps = [
  {
    target: "body",
    content: (
      <>
        <h1 className="head-alt-sm mb-4">Vítej na celostátním fóru 2024</h1>
        <p className="leading-snug text-base">
            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>
      </>
    ),
    placement: "center",
    disableBeacon: true,
  },
  {
    target: ".joyride-login",
    content: (
      <>
        <h1 className="head-alt-sm mb-4">Jsi člen či příznivec? Přihlaš se</h1>
        <p className="leading-snug text-base">
          Pokud jsi člen strany nebo registrovaný příznivec, je rozhodně dobrý
          nápad se přihlásit. Budeš tak moci přidávat příspěvky v rozpravě a
          palcovat je.
        </p>
      </>
    ),
  },
  {
    target: ".joyride-player",
    content: (
      <>
        <h1 className="head-alt-sm mb-4">Video stream</h1>
        <p className="leading-snug text-base">
          Zde můžeš sledovat přímý přenos z jednání. Přenos má drobné zpoždění,
          tak s tím počítej.
        </p>
      </>
    ),
    placement: "bottom",
  },
  {
    target: ".joyride-posts",
    content: (
      <>
        <h1 className="head-alt-sm mb-4">Příspěvky v rozpravě</h1>
        <div className="leading-snug text-base space-y-2">
          <p>
            Předsedající pro každý bod programu může otevřít či uzavřít
            rozpravu. V rámci rozpravy je možné přidávat běžné diskusní
            příspěvky, nebo návrhy postupu.
          </p>
          <p>
            <strong>Běžné příspěvky</strong> se zobrazí ihned po přidání.
          </p>
          <p>
            U příspěvků se též zobrazuje celková míra podpory. Legenda barevného
            odlišení je následující:
          </p>

          <ul className="unordered-list unordered-list--dense">
            <li>
              <div className="px-1 text-sm font-bold inline-block bg-green-400 text-white">
                Zelenou
              </div>{" "}
              je označen příspěvek, na kterém je konsensus, nebo takový, který
              získal podporu skupiny členů.
            </li>
            <li>
              <div className="px-1 text-sm font-bold inline-block bg-yellow-400 text-grey-300">
                Žlutou
              </div>{" "}
              je označen příspěvek, který podporu teprve sbírá.
            </li>
            <li>
              <div className="px-1 text-sm font-bold inline-block bg-red-600 text-white">
                Červeně
              </div>{" "}
              je označen příspěvek, který má spíše negativní odezvu.
            </li>
            <li>
              <div className="px-1 text-sm font-bold inline-block bg-grey-125 text-grey-200">
                Šedivě
              </div>{" "}
              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: "bottom",
  },
  {
    target: ".joyride-announcements",
    content: (
      <>
        <h1 className="head-alt-sm mb-4">Oblast pro oznámení</h1>
        <p className="leading-snug text-base">
          V této oblasti se zobrazují oznámení podstatných událostí v rámci
          jednání, jako například nové rozhodující hlasování, nebo třeba nový
          hlasovatelný návrh postupu.
        </p>
      </>
    ),
    placement: "left",
  },
  {
    target: "body",
    content: (
      <>
        <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í.
        </p>
      </>
    ),
    placement: "center",
  },
];
Original line number Diff line number Diff line
import React from "react";
import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import classNames from "classnames";
import { format } from "date-fns";
import { format, isToday } from "date-fns";

import Chip from "components/Chip";
import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu";
import PostScore from "components/posts/PostScore";
import Thumbs from "components/Thumbs";

const Post = ({
@@ -13,26 +15,49 @@ const Post = ({
  type,
  ranking,
  content,
  modified,
  seen,
  archived,
  state,
  historyLog,
  dimIfArchived = true,
  displayActions = false,
  currentUser,
  supportThreshold,
  canThumb,
  reportSeen = true,
  onLike,
  onDislike,
  onHide,
  onBanUser,
  onUnbanUser,
  onInviteUser,
  onAnnounceProcedureProposal,
  onAcceptProcedureProposal,
  onRejectProcedureProposal,
  onRejectProcedureProposalByChairman,
  onEdit,
  onArchive,
  onSeen,
  ...props
}) => {
  const { ref, inView } = useInView({
    threshold: 0.8,
    trackVisibility: true,
    delay: 1000,
    skip: !reportSeen,
    triggerOnce: true,
  });

  useEffect(() => {
    if (inView && onSeen) {
      onSeen();
    }
  }, [inView, onSeen]);

  const wrapperClassName = classNames(
    "flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2",
    "flex items-start p-4 lg:p-2 lg:py-3 lg:-mx-2 transition duration-500",
    {
      "bg-yellow-100 bg-opacity-50": !seen,
      "opacity-25 hover:opacity-100 transition-opacity duration-200":
        dimIfArchived && !!archived,
      "opacity-25 hover:opacity-100": dimIfArchived && !!archived,
    },
    className
  );
@@ -62,28 +87,53 @@ const Post = ({
    labels.push(
      {
        pending: (
          <Chip key="state__pending" condensed color="grey-500">
          <Chip
            key="state__pending"
            condensed
            color="grey-500"
            aria-label="Návrh čekající na zpracování"
          >
            Čeká na zpracování
          </Chip>
        ),
        announced: (
          <Chip key="state__announced" condensed color="blue-300">
            Vyhlášený
          <Chip
            key="state__announced"
            condensed
            color="blue-300"
            aria-label="Návrh k hlasování"
          >
            K hlasování
          </Chip>
        ),
        accepted: (
          <Chip key="state__accepted" condensed color="green-400">
          <Chip
            key="state__accepted"
            condensed
            color="green-400"
            aria-label="Schválený návrh"
          >
            Schválený
          </Chip>
        ),
        rejected: (
          <Chip key="state__rejected" condensed color="red-600">
          <Chip
            key="state__rejected"
            condensed
            color="red-600"
            aria-label="Zamítnutý návrh"
          >
            Zamítnutý
          </Chip>
        ),
        "rejected-by-chairman": (
          <Chip key="state__rejected-by-chairmen" condensed color="red-600">
            Zamítnutý předsedajícím
          <Chip
            key="state__rejected-by-chairmen"
            condensed
            color="red-600"
            aria-label="Návrh zamítnutý předsedajícím"
          >
            Zamítnutý předs.
          </Chip>
        ),
      }[state]
@@ -103,60 +153,102 @@ const Post = ({
    );
  }

  const isModified =
    (historyLog || []).filter(
      (logRecord) =>
        logRecord.attribute === "content" && logRecord.originator === "chairman"
    ).length > 0;
  const isChairman = currentUser && currentUser.role === "chairman";

  const showAnnounceAction =
    type === "procedure-proposal" && state === "pending";
    isChairman && type === "procedure-proposal" && state === "pending";
  const showAcceptAction =
    type === "procedure-proposal" && state === "announced";
    isChairman && type === "procedure-proposal" && state === "announced";
  const showRejectAction =
    type === "procedure-proposal" && state === "announced";
  const showBanAction = true;
  const showHideAction = !archived;
    isChairman && type === "procedure-proposal" && state === "announced";
  const showRejectByChairmanAction =
    isChairman &&
    type === "procedure-proposal" &&
    ["announced", "pending"].includes(state);
  const showEditAction =
    isChairman ||
    (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;

  // Show actions dropdown if any of actions is available.
  const showActions = [
    showAnnounceAction,
    showAcceptAction,
    showRejectAction,
    showRejectByChairmanAction,
    showEditAction,
    showBanAction,
    showUnbanAction,
    showInviteAction,
    showHideAction,
    showArchiveAction,
  ].some((item) => !!item);

  const htmlContent = {
    __html: content,
  };

  const thumbsVisible = !archived && (type === "post" || state === "announced");

  return (
    <div className={wrapperClassName}>
    <div className={wrapperClassName} ref={ref} {...props}>
      <img
        src="http://placeimg.com/100/100/people"
        className="w-8 h-8 lg:w-14 lg:h-14 rounded mr-3"
        src={`https://a.pirati.cz/piratar/200/${author.username}.jpg`}
        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 lg:mt-0 lg:ml-2">
                  <span className="text-grey-200 text-sm">{author.group}</span>
                  <span className="text-grey-200 ml-1 text-sm">
                    @ {format(datetime, "H:mm")}
                    {isModified && (
                      <span className="text-grey-200 text-xs ml-2 underline">
                        (Upraveno přesdedajícím)
                <div className="mt-1 xl:mt-0 xl:ml-2 leading-tight">
                  <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,
                      isToday(datetime) ? "H:mm" : "dd. MM. H:mm"
                    )}
                    {modified && (
                      <span className="text-grey-200 text-xs block md:inline md:ml-2">
                        (upraveno)
                      </span>
                    )}
                  </span>
                </div>
              </div>
              <div className="flex flex-row flex-wrap lg:flex-no-wrap lg:items-center mt-1 xl:mt-0 xl:ml-2 space-x-2">
              <div className="hidden lg:flex flex-row flex-wrap lg:flex-no-wrap lg:items-center mt-1 xl:mt-0 xl:ml-2 space-x-2">
                {labels}
              </div>
            </div>
            <div className="flex items-center space-x-4">
            <div className="flex items-center">
              {thumbsVisible && (
                <Thumbs
                  likes={ranking.likes}
                  dislikes={ranking.dislikes}
                  readOnly={!canThumb}
                  onLike={onLike}
                  onDislike={onDislike}
                canThumb={ranking.myVote === "none"}
                  myVote={ranking.myVote}
                />
              )}
              <PostScore
                className="ml-2"
                postType={type}
                ranking={ranking}
                rankingReadonly={!thumbsVisible}
                supportThreshold={supportThreshold}
              />
              {displayActions && (
                <DropdownMenu right>
              {showActions && (
                <DropdownMenu right className="pl-4 static">
                  {showAnnounceAction && (
                    <DropdownMenuItem
                      onClick={onAnnounceProcedureProposal}
@@ -178,6 +270,20 @@ const Post = ({
                      title="Zamítnout procedurální návrh"
                    />
                  )}
                  {showRejectByChairmanAction && (
                    <DropdownMenuItem
                      onClick={onRejectProcedureProposalByChairman}
                      icon="ico--thumbs-down"
                      title="Zamítnout procedurální návrh předsedajícím"
                    />
                  )}
                  {showEditAction && (
                    <DropdownMenuItem
                      onClick={onEdit}
                      icon="ico--pencil"
                      title="Upravit příspěvek"
                    />
                  )}
                  {showBanAction && (
                    <DropdownMenuItem
                      onClick={onBanUser}
@@ -185,6 +291,20 @@ const Post = ({
                      title="Zablokovat uživatele"
                    />
                  )}
                  {showUnbanAction && (
                    <DropdownMenuItem
                      onClick={onUnbanUser}
                      icon="ico--lock-open"
                      title="Odblokovat uživatele"
                    />
                  )}
                  {showInviteAction && (
                    <DropdownMenuItem
                      onClick={onInviteUser}
                      icon="ico--phone"
                      title="Pozvat uživatele do Jitsi"
                    />
                  )}
                  {showHideAction && (
                    <DropdownMenuItem
                      onClick={onHide}
@@ -192,17 +312,28 @@ const Post = ({
                      title="Skrýt příspěvek"
                    />
                  )}
                  {showArchiveAction && (
                    <DropdownMenuItem
                      onClick={onArchive}
                      icon="ico--drawer"
                      title="Archivovat příspěvek"
                    />
                  )}
                </DropdownMenu>
              )}
            </div>
          </div>
        </div>
        <p className="text-sm lg:text-base text-black leading-normal">
          {content}
        </p>
        <div className="flex lg:hidden flex-row flex-wrap my-2 space-x-2">
          {labels}
        </div>
        <div
          className="text-sm lg:text-base text-black leading-normal content-block overflow-x-auto overflow-y-hidden mt-1"
          dangerouslySetInnerHTML={htmlContent}
        ></div>
      </div>
    </div>
  );
};

export default Post;
export default React.memo(Post);
+96 −0
Original line number Diff line number Diff line
import React, { useState } from "react";

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

const PostEditModal = ({
  post,
  onCancel,
  onConfirm,
  confirming,
  error,
  ...props
}) => {
  const [text, setText] = useState(post.content);
  const [textError, setTextError] = useState(null);

  const onTextInput = (newText) => {
    setText(newText);

    if (newText !== "") {
      if (newText.length >= 1024) {
        setTextError("Maximální délka příspěvku je 1024 znaků.");
      } else {
        setTextError(null);
      }
    }
  };

  const confirm = (evt) => {
    evt.preventDefault();

    if (!!text) {
      onConfirm(text);
    } else {
      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 className="elevation-21">
          <CardBody>
            <div className="flex items-center justify-between mb-4">
              <CardHeadline>Upravit text příspěvku</CardHeadline>
              <button onClick={onCancel} type="button">
                <i className="ico--cross"></i>
              </button>
            </div>
            <MarkdownEditor
              value={text}
              onChange={onTextInput}
              error={textError}
              placeholder="Vyplňte text příspěvku"
              toolbarCommands={[
                ["header", "bold", "italic", "strikethrough"],
                ["link", "quote"],
                ["unordered-list", "ordered-list"],
              ]}
            />
            {error && (
              <ErrorMessage className="mt-2">
                Při editaci došlo k problému: {error}
              </ErrorMessage>
            )}
          </CardBody>
          <CardActions right className="space-x-1">
            <Button
              hoverActive
              color="blue-300"
              className="text-sm"
              disabled={textError || confirming}
              loading={confirming}
              onClick={confirm}
            >
              Uložit
            </Button>
            <Button
              hoverActive
              color="red-600"
              className="text-sm"
              onClick={onCancel}
            >
              Zrušit
            </Button>
          </CardActions>
        </Card>
      </form>
    </Modal>
  );
};

export default PostEditModal;
Original line number Diff line number Diff line
import React from "react";
import React, { useCallback, useMemo, useState } from "react";
import classNames from "classnames";

import Post from "./Post";
@@ -6,57 +6,110 @@ import Post from "./Post";
const PostList = ({
  className,
  items,
  showAddPostCta,
  currentUser,
  supportThreshold,
  canThumb,
  dimArchived,
  onLike,
  onDislike,
  onHide,
  onBanUser,
  onUnbanUser,
  onInviteUser,
  onAnnounceProcedureProposal,
  onAcceptProcedureProposal,
  onRejectProcedureProposal,
  dimArchived,
  onRejectProcedureProposalByChairman,
  onEdit,
  onArchive,
  onSeen,
}) => {
  const buildHandler = (responderFn) => (post) => (evt) => {
    evt.preventDefault();
    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
  );
  const onPostAcceptProcedureProposal = buildHandler(onAcceptProcedureProposal);
  const onPostRejectProcedureProposal = buildHandler(onRejectProcedureProposal);
  const onPostRejectProcedureProposalByChairman = buildHandler(
    onRejectProcedureProposalByChairman
  );

  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) => (
      {windowItems.map((item, idx) => (
        <Post
          key={item.id}
          datetime={item.datetime}
          author={item.author}
          type={item.type}
          state={item.state}
            content={item.content}
          content={item.contentHtml}
          ranking={item.ranking}
            historyLog={item.historyLog}
          modified={item.modified}
          seen={item.seen}
          reportSeen={!item.seen || idx === window - 1}
          archived={item.archived}
            displayActions={true}
          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š
          první?
        </p>
      )}
    </div>
  );
};
+63 −0
Original line number Diff line number Diff line
import React from "react";
import classNames from "classnames";

const PostScore = ({
  postType,
  ranking,
  supportThreshold,
  rankingReadonly,
  className,
}) => {
  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 && dislikes > 0 && score < supportThreshold,
        "bg-green-400 text-white":
          score >= supportThreshold || (score > 0 && dislikes <= 0),
      }
    : "bg-grey-125 text-grey-200";

  let title;

  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
        }.`;
      } else {
        title = `Aktuální podpora je ${score} hlasů, což je dostatek pro získání podpory skupiny členů (vyžaduje alespoň ${supportThreshold} hlasů).`;
      }
    } else {
      title = `Příspěvek získal ${score} hlasů bez jakýchkoliv hlasů proti a má tedy konkludentní podporu.`;
    }
  } else {
    title = `Příspěvek získal podporu ${score} hlasů.`;
  }

  return (
    <span
      className={classNames(
        "p-1 text-sm flex items-center space-x-1",
        coloring,
        className
      )}
      style={{ cursor: "help" }}
      aria-label={title}
      data-tip={title}
      data-type="dark"
      data-place="top"
    >
      <i className="ico--power" />
      <span className="font-bold">{score}</span>
    </span>
  );
};

export default React.memo(PostScore);
Original line number Diff line number Diff line
import React from "react";

import Button from "components/Button";
import {
  Card,
  CardActions,
  CardBody,
  CardBodyText,
  CardHeadline,
} from "components/cards";
import ErrorMessage from "components/ErrorMessage";

import Modal from "./Modal";

const RejectPostModalConfirm = ({
  title,
  children,
  yesActionLabel = "OK",
  yesAndArchiveActionLabel = "OK",
  cancelActionLabel = "Zrušit",
  onCancel,
  onConfirm,
  onConfirmAndArchive,
  confirming,
  error,
  ...props
}) => {
  return (
    <Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
      <Card className="elevation-21">
        <CardBody>
          <div className="flex items-center justify-between mb-4">
            <CardHeadline>{title}</CardHeadline>
            <button onClick={onCancel} type="button">
              <i className="ico--cross"></i>
            </button>
          </div>
          <CardBodyText>{children}</CardBodyText>
          {error && (
            <ErrorMessage className="mt-2">
              Při provádění akce došlo k problému: {error}
            </ErrorMessage>
          )}
        </CardBody>
        <CardActions right className="space-x-1">
          <Button
            hoverActive
            color="blue-300"
            className="text-sm"
            onClick={onConfirm}
            loading={confirming}
          >
            {yesActionLabel}
          </Button>
          <Button
            hoverActive
            color="blue-300"
            className="text-sm"
            onClick={onConfirm}
            loading={confirming}
          >
            {yesAndArchiveActionLabel}
          </Button>
          <Button
            hoverActive
            color="red-600"
            className="text-sm"
            onClick={onCancel}
          >
            {cancelActionLabel}
          </Button>
        </CardActions>
      </Card>
    </Modal>
  );
};

export default React.memo(RejectPostModalConfirm);
Original line number Diff line number Diff line
import React, { useState } from "react";

import Button from "components/Button";
import { Card, CardActions, CardBody, CardHeadline } from "components/cards";
import Modal from "components/modals/Modal";

const ProgramEntryEditModal = ({
  programEntry,
  onCancel,
  onConfirm,
  ...props
}) => {
  const [text, setText] = useState(programEntry.title);

  const onTextInput = (evt) => {
    setText(evt.target.value);
  };

  const confirm = (evt) => {
    if (!!text) {
      onConfirm(text);
    }
  };

  return (
    <Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
      <Card className="elevation-21">
        <CardBody>
          <div className="flex items-center justify-between mb-4">
            <CardHeadline>Upravit název programového bodu</CardHeadline>
            <button onClick={onCancel}>
              <i className="ico--cross"></i>
            </button>
          </div>
          <div className="form-field">
            <label className="form-field__label" htmlFor="field">
              Nový název
            </label>
            <div className="form-field__wrapper form-field__wrapper--shadowed">
              <input
                type="text"
                className="text-input form-field__control"
                value={text}
                onChange={onTextInput}
                placeholder="Vyplňte nový název"
              />
            </div>
          </div>
        </CardBody>
        <CardActions right className="space-x-1">
          <Button
            hoverActive
            color="blue-300"
            className="text-sm"
            onClick={confirm}
          >
            Uložit
          </Button>
          <Button
            hoverActive
            color="red-600"
            className="text-sm"
            onClick={onCancel}
          >
            Zrušit
          </Button>
        </CardActions>
      </Card>
    </Modal>
  );
};

export default ProgramEntryEditModal;

src/config.js

deleted100644 → 0
+0 −3
Original line number Diff line number Diff line
export default {
  styleguideUrl: "https://styleguide.pir-test.eu/latest",
};
Original line number Diff line number Diff line
import React, { useState } from "react";
import classNames from "classnames";

import { addAnnouncement } from "actions/announcements";
import Button from "components/Button";
import ErrorMessage from "components/ErrorMessage";
import MarkdownEditor from "components/mde/MarkdownEditor";
import { useActionState } from "hooks";
import { urlRegex } from "utils";

const AddAnnouncementForm = ({ className }) => {
  const [text, setText] = useState("");
  const [link, setLink] = useState("");
  const [textError, setTextError] = useState(null);
  const [linkError, setLinkError] = useState(null);
  const [type, setType] = useState("announcement");

  const onTextInput = (evt) => {
    setText(evt.target.value);
  const [adding, addingError] = useActionState(addAnnouncement, {
    content: text,
    link,
    type,
  });

  const onTextInput = (newText) => {
    setText(newText);

    if (newText !== "") {
      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 onAdd = (evt) => {
    if (!!text) {
      addAnnouncement.run({ content: text });
  const onAdd = async (evt) => {
    evt.preventDefault();

    let preventAction = false;
    const payload = {
      content: text,
      type,
    };

    if (!text) {
      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) {
      setLinkError("Zadejte platnou URL.");
      preventAction = true;
    } else {
      payload.link = link;
    }

    if (preventAction) {
      return;
    }

    const result = await addAnnouncement.run({ content: text, link, type });

    if (!result.error) {
      setText("");
      setLink("");
      setTextError(null);
      setLinkError(null);
    }
  };

  return (
    <div className={className}>
      <div className="form-field">
        <div className="form-field__wrapper form-field__wrapper--shadowed">
          <textarea
            className="text-input form-field__control "
    <form className={className} onSubmit={onAdd}>
      {addingError && (
        <ErrorMessage>
          Při přidávání oznámení došlo k problému: {addingError}.
        </ErrorMessage>
      )}
      <div className="grid grid-cols-1 gap-4">
        <div
          className="form-field"
          onChange={(evt) => setType(evt.target.value)}
        >
          <div className="form-field__wrapper text-sm">
            <div className="radio form-field__control">
              <label>
                <input
                  type="radio"
                  name="announcementType"
                  value="announcement"
                  defaultChecked
                />
                <span>Oznámení</span>
              </label>
            </div>

            <div className="radio form-field__control">
              <label>
                <input type="radio" name="announcementType" value="voting" />
                <span>Rozhodující hlasování</span>
              </label>
            </div>
          </div>
        </div>

        <MarkdownEditor
          value={text}
            rows="3"
            cols="40"
            placeholder="Vyplňte text oznámení"
          onChange={onTextInput}
          ></textarea>
          error={textError}
          placeholder="Vyplňte text oznámení"
          toolbarCommands={[
            ["bold", "italic", "strikethrough"],
            ["link", "unordered-list", "ordered-list"],
          ]}
          minEditorHeight={100}
        />

        <div
          className={classNames("form-field", {
            hidden: 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>
      </div>

      <Button
        onClick={onAdd}
        className="text-sm mt-2"
        type="submit"
        className="text-sm mt-4"
        hoverActive
        disabled={!text}
        loading={adding}
        disabled={textError || linkError || adding}
        fullwidth
      >
        Přidat oznámení
      </Button>
    </div>
    </form>
  );
};

Original line number Diff line number Diff line
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 [error, setError] = useState(null);
  const [addingPost, addingPostError] = useActionState(addPost, {
    content: text,
  });
  const [addingProposal, addingProposalError] = useActionState(addProposal, {
    content: text,
  });

  const onTextInput = (evt) => {
    setText(evt.target.value);
  };
  const apiError = addingPostError || addingProposalError;
  const is429ApiError =
    apiError &&
    apiError.toString().indexOf("Unexpected status code 429") !== -1;

  const onAddPost = (evt) => {
    if (!!text) {
      addPost.run({ content: text });
      setText("");
  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 !== "") {
      if (newText.length >= 1024) {
        setError("Maximální délka příspěvku je 1024 znaků.");
      } else {
        setError(null);
      }
    }
  };

  const onAddProposal = (evt) => {
    evt.stopPropagation();
  const onAdd = async (evt) => {
    evt.preventDefault();

    if (!!text) {
      addProposal.run({ content: text });
      if (!error) {
        const result = await (type === "post" ? addPost : addProposal).run({
          content: text,
        });

        if (!result.error) {
          setText("");
          setExpanded(false);
          setShowAddConfirm(true);
          enqueueHideAddConfirm();
        }
      }
    } else {
      setError("Před přidáním příspěvku nezapomeňte vyplnit jeho obsah.");
    }
  };

  const buttonDropdownActionList = (
    <ul className="dropdown-button__choices bg-white text-black whitespace-no-wrap">
      <li className="dropdown-button__choice hover:bg-grey-125">
        <span className="block px-4 py-3" onClick={onAddProposal}>
          Navrhnout postup
        </span>
      </li>
    </ul>
  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}>
      <div className="form-field">
        <div className="form-field__wrapper form-field__wrapper--shadowed">
          <textarea
            className="text-input form-field__control "
    <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" : "")
        }
      >
        <form className="space-y-4" onSubmit={onAdd}>
          {apiError && is429ApiError && (
            <div className="alert alert--warning">
              <i className="alert__icon ico--clock text-lg" />
              <span>
                <strong>Zpomal!</strong> Další příspěvek můžeš přidat nejdříve
                po 1 minutě od přidání posledního.
              </span>
            </div>
          )}
          {apiError && !is429ApiError && (
            <ErrorMessage>
              Při přidávání příspěvku došlo k problému: {apiError}.
            </ErrorMessage>
          )}

          <MarkdownEditor
            ref={editorRef}
            value={text}
            rows="5"
            cols="40"
            placeholder="Vyplňte text vašeho příspěvku"
            onChange={onTextInput}
          ></textarea>
            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>
                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>
            </p>
          )}

          <div className="space-x-4">
            <Button
          onClick={onAddPost}
          disabled={!text}
              type="submit"
              disabled={error || addingPost || addingProposal}
              loading={addingPost || addingProposal}
              fullwidth
              hoverActive
          icon="ico--chevron-down"
          iconWrapperClassName="dropdown-button"
          iconChildren={buttonDropdownActionList}
              className="text-sm xl:text-base"
            >
          Přidat příspěvek
              {type === "post" && "Přidat příspěvek"}
              {type === "procedure-proposal" && "Navrhnout postup"}
            </Button>

            <span className="text-sm text-grey-200 hidden lg:inline">
@@ -76,7 +229,9 @@ const AddPostForm = ({ className }) => {
              .
            </span>
          </div>
    </div>
        </form>
      </CardBody>
    </Card>
  );
};

Original line number Diff line number Diff line
import React from "react";

import {
  deleteAnnouncement,
  loadAnnouncements,
  markSeen,
  updateAnnouncement,
} from "actions/announcements";
import AnnouncementEditModal from "components/annoucements/AnnouncementEditModal";
import AnnouncementList from "components/annoucements/AnnouncementList";
import { AnnouncementStore } from "stores";
import { CardBody } from "components/cards";
import ErrorMessage from "components/ErrorMessage";
import ModalConfirm from "components/modals/ModalConfirm";
import { useItemActionConfirm } from "hooks";
import { AnnouncementStore, AuthStore } from "stores";

const AnnoucementsContainer = () => {
  const items = AnnouncementStore.useState((state) => state.items);
const AnnoucementsContainer = ({ className }) => {
  const { 2: loadResult } = loadAnnouncements.useWatch();

  const onEdit = (announcement) => {
    console.log("edit", announcement);
  };
  const onDelete = (announcement) => {
    console.log("delete", announcement);
  };
  const [
    itemToDelete,
    setItemToDelete,
    onDeleteConfirm,
    onDeleteCancel,
    deleteState,
  ] = useItemActionConfirm(deleteAnnouncement);

  const [
    itemToEdit,
    setItemToEdit,
    onEditConfirm,
    onEditCancel,
    editState,
  ] = useItemActionConfirm(updateAnnouncement, (item, payload) => ({
    item,
    payload,
  }));

  const { isAuthenticated, user } = AuthStore.useState();
  const items = AnnouncementStore.useState((state) =>
    state.itemIds.map((id) => state.items[id])
  );

  return (
    <div className={className}>
      {loadResult && loadResult.error && (
        <CardBody>
          <ErrorMessage>
            Oznámení se nepodařilo načíst: {loadResult.message}
          </ErrorMessage>
        </CardBody>
      )}
      <AnnouncementList
        items={items}
      displayActions={true}
      onDelete={onDelete}
      onEdit={onEdit}
        canRunActions={isAuthenticated && user.role === "chairman"}
        onDelete={setItemToDelete}
        onEdit={setItemToEdit}
        onSeen={markSeen}
      />
      <ModalConfirm
        isOpen={!!itemToDelete}
        onConfirm={onDeleteConfirm}
        onCancel={onDeleteCancel}
        confirming={deleteState.loading}
        error={deleteState.error}
        title="Opravdu smazat?"
        yesActionLabel="Smazat"
      >
        Tato akce je nevratná. Opravdu chcete toto oznámení smazat?
      </ModalConfirm>
      {itemToEdit && (
        <AnnouncementEditModal
          isOpen={true}
          announcement={itemToEdit}
          onConfirm={onEditConfirm}
          onCancel={onEditCancel}
          confirming={editState.loading}
          error={editState.error}
        />
      )}
    </div>
  );
};

+80 −0
Original line number Diff line number Diff line
import React from "react";
import { Link } from "react-router-dom";
import { format, isToday } from "date-fns";
import pick from "lodash/pick";

import { GlobalInfoStore, ProgramStore } from "stores";

const GlobalStats = () => {
  const {
    onlineUsers,
    onlineMembers,
    groupSizeHalf,
  } = GlobalInfoStore.useState((state) =>
    pick(state, ["onlineUsers", "onlineMembers", "groupSizeHalf"])
  );
  const { currentId, scheduleIds, items } = ProgramStore.useState((state) =>
    pick(state, ["currentId", "scheduleIds", "items"])
  );

  const nextProgramEntryId = scheduleIds
    ? scheduleIds[currentId ? scheduleIds.indexOf(currentId) + 1 : 0]
    : null;

  const nextProgramEntry = nextProgramEntryId
    ? items[nextProgramEntryId]
    : null;

  const nextProgramEntryCaption = nextProgramEntry
    ? `${nextProgramEntry.title} @ ${format(
        nextProgramEntry.expectedStartAt,
        isToday(nextProgramEntry.expectedStartAt) ? "H:mm" : "dd. MM. H:mm"
      )}`
    : null;

  return (
    <div className="bg-grey-50 flex space-x-4 leading-normal px-4 py-2 text-2xs md:text-xs lg:text-sm text-grey-300 whitespace-no-wrap">
      <div
        data-tip="Počet přihlášených členů Pirátské strany."
        data-tip-at="bottom"
      >
        <strong>{onlineMembers}</strong>{" "}
        <span>
          {onlineMembers === 1 && "člen online"}
          {onlineMembers > 1 && onlineMembers <= 4 && "členové online"}
          {(onlineMembers === 0 || onlineMembers > 4) && "členů online"}
        </span>
      </div>
      <div
        data-tip="Celkový počet osob, které mají tuto stránku otevřenou."
        data-tip-at="bottom"
      >
        <strong>{onlineUsers}</strong> <span>online celkem</span>
      </div>
      {groupSizeHalf !== null && (
        <div
          data-tip="Velikost skupiny členů je důležitá při posuzování podpory návrhů postupu."
          data-tip-at="bottom"
        >
          <span>Vel. skupiny členů je</span> <strong>{groupSizeHalf}</strong>
        </div>
      )}
      {nextProgramEntry && (
        <div className="flex-grow text-right hidden sm:block lg:hidden xl:block truncate">
          Následuje:{" "}
          <Link
            to="/program"
            className="font-bold"
            aria-label={nextProgramEntryCaption}
            data-tip={"Následuje bod " + nextProgramEntryCaption}
            data-tip-at="bottom"
          >
            {nextProgramEntryCaption}
          </Link>
        </div>
      )}
    </div>
  );
};

export default GlobalStats;
+93 −0
Original line number Diff line number Diff line
import React from "react";

import { loadMe } from "actions/users";
import Button from "components/Button";
import { CardBody } from "components/cards";
import ErrorMessage from "components/ErrorMessage";
import { useActionState } from "hooks";
import { AuthStore } from "stores";

const JitsiInviteCard = () => {

  // docasne zablokovano
  return null;

  const { showJitsiInvitePopup, jitsiPopupDismissed } = AuthStore.useState();
  const [loading, errorMessage] = useActionState(loadMe);

  const openJitsiWindow = async () => {
    const result = await loadMe.run();

    if (!result.error) {
      window.open(result.payload.jitsi_url);
    }

    AuthStore.update((state) => {
      state.jitsiPopupDismissed = true;
    });
  };

  const dismissPopup = () => {
    AuthStore.update((state) => {
      state.jitsiPopupDismissed = true;
    });
  };

  if (!showJitsiInvitePopup) {
    return null;
  }

  if (jitsiPopupDismissed) {
    return (
      <Button
        color="violet-500"
        className="btn--fullwidth"
        onClick={openJitsiWindow}
        loading={loading}
        icon="ico--jitsi"
      >
        Připojit se k Jitsi
      </Button>
    );
  }

  return (
    <div className="lg:card lg:elevation-16 bg-violet-300 relative container-padding--zero md:container-padding--auto">
      <i className="ico--jitsi text-9xl mr-2 text-violet-500 absolute right-0 top-0 opacity-25 z-0" />
      <CardBody className="p-4 lg:p-8 text-white relative z-10">
        <div className="flex items-center justify-between mb-4">
          <h2 className="head-heavy-xs">
            <span>Pozvánka do Jitsi</span>
          </h2>
          <button
            type="button"
            onClick={dismissPopup}
            aria-label="Zavřít"
            data-tip="Zavřít"
          >
            <i className="ico--cross"></i>
          </button>
        </div>
        {errorMessage && (
          <ErrorMessage>
            Při načítání URL Jitsi kanálu došlo k problému: {errorMessage}.
          </ErrorMessage>
        )}
        <p className="leading-snug text-sm mb-4">
          Někdo tě pozval do <strong>chráněného Jitsi kanálu</strong>{" "}
          celeostátního fóra. Ke kanálu se připojíš kliknutím na tlačítko níže.
        </p>
        <Button
          color="violet-500"
          className="btn--fullwidth"
          onClick={openJitsiWindow}
          loading={loading}
        >
          Připojit se k Jitsi
        </Button>
      </CardBody>
    </div>
  );
};

export default JitsiInviteCard;
Original line number Diff line number Diff line
import React, { useCallback } from "react";
import pick from "lodash/pick";
import React from "react";

import Chip from "components/Chip";
import Dropdown from "components/Dropdown";
import { PostStore } from "stores";
import { updateWindowPosts } from "utils";

const PostFilters = () => {
  const { window, filters } = PostStore.useState((state) =>
    pick(state, ["window", "filters", "items"])
  );
  const filters = PostStore.useState((state) => state.filters);

  const flagsOptions = [
    { title: "Vše", value: "all" },
@@ -25,19 +21,13 @@ const PostFilters = () => {
    { title: "Jen návrhy", value: "proposalsOnly" },
    { title: "Jen příspěvky", value: "discussionOnly" },
  ];
  const hasNextPage = window.page * window.perPage < window.itemCount;
  const hasPrevPage = window.page > 1;

  const setFilter = (prop, newValue, resetPage = true) => {
  const setFilter = (prop, newValue) => {
    PostStore.update((state) => {
      state.filters[prop] = newValue;
      state.window.itemCount = state.window.items.length;

      updateWindowPosts(state);

      if (resetPage) {
        state.window.page = 1;
      }
    });
  };

@@ -45,27 +35,9 @@ const PostFilters = () => {
  const onSortChange = (newValue) => setFilter("sort", newValue, false);
  const onTypeChange = (newValue) => setFilter("type", newValue);

  const onNextPage = useCallback(() => {
    if (hasNextPage) {
      PostStore.update((state) => {
        state.window.page = state.window.page + 1;
      });
    }
  }, [hasNextPage]);
  const onPrevPage = useCallback(() => {
    if (hasPrevPage) {
      PostStore.update((state) => {
        state.window.page = state.window.page - 1;
      });
    }
  }, [hasPrevPage]);

  const enabledPaginatorClass = "cursor-pointer text-xs";
  const disabledPaginatorClass = "opacity-25 cursor-not-allowed text-xs";

  return (
    <div className="flex flex-col space-y-2 xl:space-y-0 xl:space-x-8 xl:flex-row xl:items-center">
      <div className="-mx-1">
      <div className="-mx-1 joyride-filters">
        <Dropdown
          value={filters.flags}
          onChange={onFlagsChange}
@@ -85,29 +57,6 @@ const PostFilters = () => {
          className="text-xs ml-1 mt-2 xl:mt-0"
        />
      </div>

      <div>
        <Chip
          color="grey-125"
          className={
            hasPrevPage ? enabledPaginatorClass : disabledPaginatorClass
          }
          hoveractive
          onClick={onPrevPage}
        >
          <span className="ico--chevron-left"></span>
        </Chip>
        <Chip
          color="grey-125"
          className={
            hasNextPage ? enabledPaginatorClass : disabledPaginatorClass
          }
          hoveractive
          onClick={onNextPage}
        >
          <span className="ico--chevron-right"></span>
        </Chip>
      </div>
    </div>
  );
};

src/hooks.js

0 → 100644
+62 −0
Original line number Diff line number Diff line
import { useCallback, useState } from "react";

const baseActionParamsBuilder = (item, args) => {
  return item;
};

export const useItemActionConfirm = (actionFn, actionParamsBuilder = null) => {
  const [item, setItem] = useState(null);
  const [actionArgs, setActionArgs] = useState(null);

  const onActionConfirm = useCallback(
    async (args) => {
      if (item) {
        const newActionArgs = (actionParamsBuilder || baseActionParamsBuilder)(
          item,
          args,
        );
        setActionArgs(newActionArgs);
        const result = await actionFn.run(newActionArgs);

        if (!result.error) {
          setItem(null);
        }
      }
    },
    [item, setItem, actionFn, actionParamsBuilder, setActionArgs],
  );

  const onActionCancel = useCallback(() => {
    setItem(null);
  }, [setItem]);

  const [loading, error] = useActionState(actionFn, actionArgs);
  const unwrappedActionState = { loading, error };

  return [item, setItem, onActionConfirm, onActionCancel, unwrappedActionState];
};

export const useActionConfirm = (actionFn, actionArgs) => {
  const [showConfirm, setShowConfirm] = useState(false);

  const onActionConfirm = useCallback(async () => {
    if (showConfirm) {
      const result = await actionFn.run(actionArgs);

      if (!result.error) {
        setShowConfirm(false);
      }
    }
  }, [showConfirm, setShowConfirm, actionFn, actionArgs]);

  const onActionCancel = useCallback(() => {
    setShowConfirm(false);
  }, [setShowConfirm]);

  return [showConfirm, setShowConfirm, onActionConfirm, onActionCancel];
};

export const useActionState = (actionFn, actionArgs) => {
  const { 0: started, 1: finished, 2: result } = actionFn.useWatch(actionArgs);
  return [started && !finished, result.error ? result.message : null];
};
+15 −6
Original line number Diff line number Diff line
import React from "react";
import ReactDOM from "react-dom";
import ReactDOM from "react-dom/client";
import ReactModal from "react-modal";

import { refreshAccessToken } from "actions/users";

import App from "./App";
import * as serviceWorker from "./serviceWorker";

const root = document.getElementById("root");
const root = ReactDOM.createRoot(document.getElementById("root"));

function handleVisibilityChange() {
  if (!document.hidden) {
    refreshAccessToken();
  }
}

document.addEventListener("visibilitychange", handleVisibilityChange, false);

ReactDOM.render(
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  root
  </React.StrictMode>
);
ReactModal.setAppElement(root);
ReactModal.setAppElement(document.getElementById("root"));

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
+1 −5
Original line number Diff line number Diff line
@@ -2,14 +2,10 @@ import Keycloak from "keycloak-js";

// Setup Keycloak instance as needed
// Pass initialization options as required or leave blank to load from 'keycloak.json'
const keycloak = Keycloak({
const keycloak = new Keycloak({
  url: "https://auth.pirati.cz/auth",
  realm: "pirati",
  clientId: "cf-online",
});

// keycloak.init({
//   onLoad: "check-sso",
// });

export default keycloak;

src/markdown.js

0 → 100644
+21 −0
Original line number Diff line number Diff line
import Showdown from "showdown";
import xss from "xss";

const xssFilter = (converter) => [
  {
    type: "output",
    filter: (text) => xss(text),
  },
];

export const markdownConverter = new Showdown.Converter({
  tables: true,
  simplifiedAutoLink: true,
  strikethrough: true,
  tasklists: false,
  omitExtraWLInCodeBlocks: true,
  noHeaderId: true,
  headerLevelStart: 2,
  openLinksInNewWindow: true,
  extensions: [xssFilter],
});

src/pages/About.jsx

0 → 100644
+97 −0
Original line number Diff line number Diff line
import React from "react";
import { Helmet } from "react-helmet-async";

import { markdownConverter } from "markdown";

const content = markdownConverter.makeHtml(`
**Celostátní fórum Pirátské strany** je [podle Stanov](https://wiki.pirati.cz/rules/st#cl_8_celostatni_forum) nejvyšším orgánem strany a zasedání se podle možností účastní každý člen strany.

> #### Celostátní fórum ve výlučné působnosti:
>
> * a. volí a odvolává republikové předsednictvo,
> * b. volí a odvolává členy republikového výboru volené celostátním fórem,
> * c. zřizuje a ruší komise a odbory na celostátní úrovni,
> * d. volí a odvolává členy komise a vedoucího odboru,
> * e. schvaluje změny stanov,
> * f. projednává a schvaluje výroční zprávu předsedy strany,
> * g. mimořádně přezkoumává rozhodnutí orgánu strany,
> * h. schvaluje zakládací dokument politického institutu,
> * i. může schválit Předpis o institutu,
> * j. může volit a odvolávat některé členy správní rady politického institutu.
>
> #### Celostátní fórum dále
>
> * a. přijímá v mezích stanov další předpisy,
> * b. ukládá úkoly republikovému předsednictvu a republikovému výboru,
> * c. může projednávat a schvalovat základní programové a ideové dokumenty,
> * d. má veškerou působnost, kterou stanovy neurčují jinému orgánu strany.


### Zasedání na Internetu

Zasedání Celostátního fóra může z důvodu mimořádných okolností probíhat na Internetu. Postup zasedání na Internetu je definován §42a Jednacího řádu Celostátního fóra v následujícím znění:

> **(1)** Pokud mimořádné okolnosti nedovolují konání běžného zasedání, může, v rámci krizového řízení, republikové předsednictvo pověřit předsedu strany svoláním zasedání na Internetu nebo změnou již svolaného běžného zasedání na zasedání na Internetu.
>
> **(2)** Při zasedání na Internetu jednají účastníci zasedání ve vzájemné okamžité součinnosti přes Internet s použitím určených systémů strany, případně systémů třetích stran.
>
> **(3)** Zasedání na Internetu předseda strany svolá tím, že členům řádně oznámí datum, dobu a jeho organizátora, a to nejméně 40 dnů předem. Nejméně 14 dní před začátkem zasedání organizátor oznámí zejména:
>
> * a) způsoby pro sledování veřejného přenosu ze zasedání,
> * b) způsob pro registraci přítomnosti účastníků v průběhu zasedání,
> * c) způsob pro účast v jednání zvukem a obrazem,
> * d) způsob, kterým mohou přítomní členové a příznivci v rozpravě písemně pokládat dotazy a připomínky a vyjádřit jim podporu,
> * e) způsob, kterým mohou přítomní členové předkládat písemně procedurální návrhy a vyjádřit jim podporu,
> * f) způsob pro sčítané hlasování o procedurálních návrzích,
> * g) pokyny pro účast novinářů.
>
> **(4)** Právo účasti v jednání zvukem a obrazem mají zejména:
>
> * a) předsedající a další činovníci jednání,
> * b) osoby s právem na závěrečné slovo v rozpravě k bodům k rozhodnutí,
> * c) osoby určené navrhovatelem bodu v rozpravě k jiným bodům,
> * d) další osoby, pro něž je schválen takový postup.
>
> **(5)** Jinak se při zasedání na Internetu postupuje přiměřeně jako při běžném zasedání.
>

### Další informace

* [Stanovy České pirátské strany](https://wiki.pirati.cz/rules/st)
* [Jednací řád celostátního fóra](https://wiki.pirati.cz/rules/jdr)
`);

const About = () => {
  const htmlContent = {
    __html: content,
  };
  return (
    <>
      <Helmet>
        <title>Co je to celostátní fórum? | CF 2024 | Pirátská strana</title>
        <meta
          name="description"
          content="Nevíte co je to celostátní fórum České pirátské strany? Tady se dočtete vše potřebné."
        />
        <meta
          property="og:title"
          content="Co je to celostátní fórum? | CF 2024 | Pirátská strana"
        />
        <meta
          property="og:description"
          content="Nevíte co je to celostátní fórum České pirátské strany? Tady se dočtete vše potřebné."
        />
      </Helmet>
      <article className="container container--default py-8 lg:py-24">
        <h1 className="head-alt-md lg:head-alt-lg mb-8">Celostátní fórum</h1>

        <div
          className="content-block leading-normal"
          dangerouslySetInnerHTML={htmlContent}
        />
      </article>
    </>
  );
};

export default About;

src/pages/Home.css

0 → 100644
+14 −0
Original line number Diff line number Diff line
/* auto-size iframe according to aspect ratio while keeping the 100% height */
.iframe-container {
  position: relative;
  padding-bottom: 56.25%; /* 16:9 */
  height: 0;
}

.iframe-container iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}
+351 −64

File changed.

Preview size limit exceeded, changes collapsed.

src/pages/NotFound.jsx

0 → 100644
+35 −0
Original line number Diff line number Diff line
import React from "react";
import { Helmet } from "react-helmet-async";

import Button from "components/Button";

const NotFound = () => (
  <>
    <Helmet>
      <title>404ka | CF 2024 | Pirátská strana</title>
      <meta name="description" content="Tahle stránka tu není." />
      <meta property="og:title" content="404ka | CF 2024 | Pirátská strana" />
      <meta property="og:description" content="Tahle stránka tu není." />
    </Helmet>
    <article className="container container--default py-8 lg:py-24">
      <h1 className="head-alt-base lg:head-alt-lg mb-8">
        404ka: tak tahle stránka tu není
      </h1>
      <p className="text-base lg:text-xl mb-8">
        Dostal/a ses na takzvanou „<strong>čtyřystačtyřku</strong>“, což
        znamená, že stránka, kterou jsi se pokusil/a navštívit, na tomhle webu
        není. Zkontroluj, zda máš správný odkaz.
      </p>
      <Button
        routerTo="/"
        className="text-base lg:text-xl"
        hoverActive
        fullwidth
      >
        Přejít na hlavní stránku
      </Button>
    </article>
  </>
);

export default NotFound;

File changed.

Preview size limit exceeded, changes collapsed.

src/pages/Protocol.jsx

0 → 100644
+128 −0

File added.

Preview size limit exceeded, changes collapsed.

File changed.

Preview size limit exceeded, changes collapsed.

+37 −295

File changed.

Preview size limit exceeded, changes collapsed.

+163 −1

File changed.

Preview size limit exceeded, changes collapsed.

src/ws/connection.js

0 → 100644
+123 −0

File added.

Preview size limit exceeded, changes collapsed.

+33 −0

File added.

Preview size limit exceeded, changes collapsed.

+71 −0

File added.

Preview size limit exceeded, changes collapsed.

+43 −0

File added.

Preview size limit exceeded, changes collapsed.

+162 −125

File changed.

Preview size limit exceeded, changes collapsed.