Skip to content
Snippets Groups Projects

Compare revisions

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

Source

Select target project
No results found
Select Git revision

Target

Select target project
  • to/cf-online-ui
  • vpfafrin/cf2021
2 results
Select Git revision
Show changes
Showing
with 1142 additions and 461 deletions
import findIndex from "lodash/findIndex";
import remove from "lodash/remove";
import { createAsyncAction, successResult } from "pullstate";
import keyBy from "lodash/keyBy";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { AnnouncementStore } from "stores";
import {
announcementTypeMappingRev,
createSeenWriter,
parseRawAnnouncement,
seenAnnouncementsLSKey,
syncAnnoucementItemIds,
} from "utils";
/**
* Add new announcement.
*/
export const addAnnouncement = createAsyncAction(
async ({ content }) => {
/** @type {CF2021.Announcement} */
const payload = {
id: "999",
datetime: new Date(),
type: "announcement",
content,
seen: true,
};
return successResult(payload);
export const loadAnnouncements = createAsyncAction(
async () => {
try {
const resp = await 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.push(result.payload);
state.items = keyBy(announcements, property("id"));
syncAnnoucementItemIds(state);
});
}
},
},
);
/**
* Add new announcement.
*/
export const addAnnouncement = createAsyncAction(
async ({ content, link, type }) => {
try {
const body = JSON.stringify({
content,
link,
type: announcementTypeMappingRev[type],
});
const resp = await fetchApi("/announcements", {
method: "POST",
body,
expectedStatus: 201,
});
const data = await resp.json();
return successResult(data.data);
} catch (err) {
return errorResult([], err.toString());
}
},
);
/**
......@@ -40,41 +69,52 @@ export const deleteAnnouncement = createAsyncAction(
* @param {CF2021.Announcement} item
*/
async (item) => {
return successResult(item);
},
{
postActionHook: ({ result }) => {
if (!result.error) {
AnnouncementStore.update((state) => {
remove(state.items, { id: result.payload.id });
try {
await fetchApi(`/announcements/${item.id}`, {
method: "DELETE",
expectedStatus: 204,
});
return successResult({ item });
} catch (err) {
return errorResult([], err.toString());
}
},
}
);
/**
* Update content of an announcement.
* Update an announcement.
*/
export const updateAnnouncementContent = createAsyncAction(
export const updateAnnouncement = createAsyncAction(
/**
*
* @param {CF2021.Announcement} item
* @param {string} newContent
* @param {Object} payload
*/
async ({ item, newContent }) => {
return successResult({ item, newContent });
},
{
postActionHook: ({ result }) => {
if (!result.error) {
AnnouncementStore.update((state) => {
const itemIdx = findIndex(state.items, {
id: result.payload.item.id,
});
state.items[itemIdx].content = result.payload.newContent;
async ({ item, payload }) => {
try {
const body = JSON.stringify(payload);
await fetchApi(`/announcements/${item.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult({ item, payload });
} catch (err) {
return errorResult([], err.toString());
}
},
}
);
const { markSeen: storeSeen } = createSeenWriter(seenAnnouncementsLSKey);
/**
* Mark down user saw this post already.
* @param {CF2021.Post} post
*/
export const markSeen = (post) => {
storeSeen(post.id);
AnnouncementStore.update((state) => {
state.items[post.id].seen = true;
});
};
import isArray from "lodash/isArray";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { GlobalInfoStore } from "stores";
export const loadConfig = createAsyncAction(
async () => {
try {
const resp = await fetchApi("/config");
const payload = await resp.json();
if (!isArray(payload)) {
return errorResult([], "Unexpected response format");
}
return successResult(payload);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
GlobalInfoStore.update((state) => {
result.payload.forEach((rawConfigItem) => {
if (rawConfigItem.id === "stream_url") {
state.streamUrl = rawConfigItem.value;
}
if (rawConfigItem.id === "websocket_url") {
state.websocketUrl = rawConfigItem.value;
}
if (rawConfigItem.id === "record_url") {
state.protocolUrl = rawConfigItem.value;
}
});
});
}
},
},
);
export const loadProtocol = createAsyncAction(
async () => {
const { protocolUrl } = GlobalInfoStore.getRawState();
try {
const resp = await fetch(protocolUrl);
if (resp.status !== 200) {
return errorResult([], `Unexpected status code ${resp.status}`);
}
return successResult(await resp.text());
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
GlobalInfoStore.update((state) => {
state.protocol = markdownConverter.makeHtml(result.payload);
});
}
},
},
);
import { createAsyncAction, errorResult, successResult } from "pullstate";
import fetch from "unfetch";
import { AuthStore } from "stores";
export const loadGroupMappings = createAsyncAction(
async () => {
try {
const resp = await fetch("https://iapi.pirati.cz/v1/groups");
const mappings = await resp.json();
return successResult(mappings);
} catch (err) {
return errorResult([], err.toString());
}
},
{
postActionHook: ({ result }) => {
if (!result.error) {
AuthStore.update((state) => {
state.groupMappings = result.payload;
});
}
},
}
);
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 like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
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());
}
return false;
},
{
postActionHook: ({ result }) => {
if (!result.error) {
if (!result.error && result.payload) {
const posts = result.payload.map(parseRawPost);
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";
const filteredPosts = filterPosts(state.filters, posts);
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
state.items = keyBy(posts, property("id"));
state.itemCount = state.items.length;
state.window = {
items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length,
};
});
}
},
}
},
);
export const removeLike = createAsyncAction(
export const like = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetchApi(`/posts/${post.id}/like`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "like") {
return errorResult();
} catch (err) {
return errorResult([], err.toString());
}
return false;
},
{
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 = "none";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "like"
? "like"
: "none";
});
}
},
}
},
);
export const dislike = createAsyncAction(
......@@ -70,162 +77,163 @@ export const dislike = createAsyncAction(
* @param {CF2021.Post} post
*/
async (post) => {
try {
await fetchApi(`/posts/${post.id}/dislike`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "none") {
return errorResult();
} catch (err) {
return errorResult([], err.toString());
}
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);
}
state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "dislike"
? "dislike"
: "none";
});
}
},
}
},
);
export const removeDislike = createAsyncAction(
/**
* @param {CF2021.Post} post
* Add new discussion post.
*/
async (post) => {
return successResult(post);
},
{
shortCircuitHook: ({ args }) => {
if (args.ranking.myVote !== "dislike") {
return errorResult();
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());
}
});
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 = "none";
if (state.filters.sort === "byScore") {
updateWindowPosts(state);
}
/**
* 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());
}
},
}
);
});
/**
* Add new discussion post.
* Hide existing post.
*/
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,
};
return successResult(payload);
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
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());
}
},
);
/**
* Add new proposal.
* Edit post content.
*/
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",
export const edit = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async ({ post, newContent }) => {
try {
const body = JSON.stringify({
content: newContent,
});
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
historyLog: [],
archived: false,
hidden: false,
seen: true,
};
{
shortCircuitHook: ({ args }) => {
const { user } = AuthStore.getRawState();
return successResult(payload);
if (!user) {
return errorResult();
}
if (user && user.isBanned) {
return errorResult();
}
return false;
},
{
postActionHook: ({ result }) => {
PostStore.update((state) => {
state.items[result.payload.id] = result.payload;
updateWindowPosts(state);
});
},
}
);
/**
* Hide existing post.
* Archive post.
*/
export const hide = createAsyncAction(
export const archive = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
return successResult(post);
},
{
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].hidden = true;
try {
const body = JSON.stringify({
is_archived: true,
});
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
}
);
/**
*
* @param {CF2021.ProposalPost} proposal
* @param {CF2021.ProposalPostState} state
*/
const updateProposalState = async (proposal, state, additionalPayload) => {
const body = JSON.stringify({
state: postsStateMappingRev[state],
...(additionalPayload || {}),
});
await fetchApi(`/posts/${proposal.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(proposal);
};
/**
* Announce procedure proposal.
*/
......@@ -233,8 +241,8 @@ export const announceProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
async (proposal) => {
return successResult(proposal);
(proposal) => {
return updateProposalState(proposal, "announced");
},
{
shortCircuitHook: ({ args }) => {
......@@ -248,14 +256,7 @@ export const announceProposal = createAsyncAction(
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].state = "announced";
});
}
},
}
);
/**
......@@ -265,29 +266,22 @@ export const acceptProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
async (proposal) => {
return successResult(proposal);
({ proposal, archive }) => {
return updateProposalState(proposal, "accepted", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.state !== "announced") {
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].state = "accepted";
});
}
},
}
);
/**
......@@ -297,27 +291,61 @@ export const rejectProposal = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
async (proposal) => {
return successResult(proposal);
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected", { is_archived: archive });
},
{
shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (args.state !== "announced") {
if (args.proposal.state !== "announced") {
return errorResult();
}
return false;
},
postActionHook: ({ result }) => {
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].state = "rejected";
},
);
/**
* Reject procedure proposal.
*/
export const rejectProposalByChairman = createAsyncAction(
/**
* @param {CF2021.ProposalPost} proposal
*/
({ proposal, archive }) => {
return updateProposalState(proposal, "rejected-by-chairman", {
is_archived: archive,
});
}
},
{
shortCircuitHook: ({ args }) => {
if (args.proposal.type !== "procedure-proposal") {
return errorResult();
}
if (!["pending", "announced"].includes(args.proposal.state)) {
return errorResult();
}
return false;
},
},
);
const { markSeen: storeSeen } = createSeenWriter(seenPostsLSKey);
/**
* Mark down user saw this post already.
* @param {CF2021.Post} post
*/
export const markSeen = (post) => {
storeSeen(post.id);
PostStore.update((state) => {
state.items[post.id].seen = true;
});
};
import { parse } from "date-fns";
import keyBy from "lodash/keyBy";
import pick from "lodash/pick";
import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api";
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 fetch("/program");
const resp = await fetchApi("/program");
const mappings = await resp.json();
return successResult(mappings);
} catch (err) {
......@@ -32,85 +38,214 @@ export const loadProgram = createAsyncAction(
"title",
"description",
"proposer",
"speakers",
]),
expectedStartAt: new Date(entry.expected_start_at),
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
? new Date(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.schedule = entries;
state.items = keyBy(entries, property("id"));
state.scheduleIds = entries.map((entry) => entry.id);
if (currentEntry) {
state.current = state.schedule.find(
(scheduleEntry) => scheduleEntry.id === currentEntry.id
);
} else {
// TODO: for testing only
state.current = state.schedule[1];
state.currentId = currentEntry.id;
}
});
}
},
},
);
/**
* 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;
}
});
}
},
},
);
/**
* Open discussion.
* End program point.
*/
export const endProgramPoint = createAsyncAction(
async () => {
return successResult();
/**
*
* @param {CF2021.ProgramScheduleEntry} programEntry
*/
async (programEntry) => {
try {
const body = JSON.stringify({
is_live: false,
});
await 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.current = null;
state.currentId = null;
});
}
},
},
);
/**
* Activate program point.
*/
export const activateProgramPoint = createAsyncAction(
/**
*
* @param {CF2021.ProgramScheduleEntry} programEntry
*/
async (programEntry) => {
try {
const body = JSON.stringify({
is_live: true,
});
await 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(
async () => {
return successResult();
/**
*
* @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) => {
state.current.discussionOpened = true;
if (state.items[result.payload.id]) {
state.items[result.payload.id].discussionOpened = true;
}
});
}
},
}
},
);
/**
* Close discussion.
*/
export const closeDiscussion = createAsyncAction(
async () => {
return successResult();
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) => {
state.current.discussionOpened = false;
if (state.items[result.payload.id]) {
state.items[result.payload.id].discussionOpened = false;
}
});
}
},
}
},
);
import { createAsyncAction, successResult } from "pullstate";
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 (userId) => {
return successResult(userId);
async (user) => {
try {
await fetchApi(`/users/${user.id}/ban`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(user);
} catch (err) {
return errorResult([], err.toString());
}
},
);
export const unban = createAsyncAction(
/**
* @param {number} userId
*/
async (user) => {
try {
await fetchApi(`/users/${user.id}/unban`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(user);
} catch (err) {
return errorResult([], err.toString());
}
},
);
export const inviteToJitsi = createAsyncAction(
/**
* @param {number} userId
*/
async (user) => {
try {
const body = JSON.stringify({
allowed: true,
});
await fetchApi(`/users/${user.id}/jitsi`, {
method: "PATCH",
body,
expectedStatus: 204,
});
return successResult(user);
} catch (err) {
return errorResult([], err.toString());
}
},
);
export const refreshAccessToken = async () => {
const { isAuthenticated } = AuthStore.getRawState();
if (!isAuthenticated) {
return;
}
try {
await keycloak.updateToken(60);
console.info("[auth] access token refreshed");
} catch (exc) {
console.warn(
"[auth] could not refresh the access token, refresh token possibly expired, logging out",
);
Sentry.setUser(null);
AuthStore.update((state) => {
state.isAuthenticated = false;
state.user = null;
state.showJitsiInvitePopup = false;
state.jitsiPopupDimissed = false;
});
PostStore.update((state) => {
state.filters.showPendingProposals = false;
updateWindowPosts(state);
});
}
};
import { createAsyncAction, errorResult, successResult } from "pullstate";
import { GlobalInfoStore } from "stores";
import { connect } from "ws/connection";
import { loadAnnouncements } from "./announcements";
import { loadPosts } from "./posts";
import { loadProgram } from "./program";
export const initializeWSChannel = createAsyncAction(async () => {
const { websocketUrl } = GlobalInfoStore.getRawState();
try {
const wsChannel = await connect({
url: websocketUrl,
onConnect: async ({ worker }) => {
// Re-load initial data once connected, this will ensure we won't lose
// any intermediate state.
await Promise.all([
loadProgram.run({}, { respectCache: false }),
loadAnnouncements.run({}, { respectCache: false }),
loadPosts.run({}, { respectCache: false }),
]);
// Once loaded, start processing the messages.
worker.start();
return true;
},
});
return successResult(wsChannel);
} catch (err) {
return errorResult([], err.toString());
}
});
import baseFetch from "unfetch";
import { AuthStore } from "./stores";
export const fetch = (url, opts) => {
export const fetchApi = async (
url,
{ headers = {}, expectedStatus = 200, method = "GET", body = null } = {},
) => {
const { isAuthenticated, user } = AuthStore.getRawState();
opts = opts || {};
opts.headers = opts.headers || {};
if (isAuthenticated) {
opts.headers.Authorization = "Bearer " + user.accessToken;
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 baseFetch(process.env.REACT_APP_API_BASE_URL + url, opts);
return response;
};
......@@ -4,14 +4,15 @@ 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(
......@@ -21,19 +22,21 @@ 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);
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>
......@@ -54,4 +57,4 @@ const Button = ({
);
};
export default Button;
export default React.memo(Button);
......@@ -26,4 +26,4 @@ const Chip = ({
);
};
export default Chip;
export default React.memo(Chip);
......@@ -25,4 +25,4 @@ const Dropdown = ({ value, options, onChange, className }) => {
);
};
export default Dropdown;
export default React.memo(Dropdown);
import React from "react";
import classNames from "classnames";
const ErrorMessage = ({ className, children }) => {
return (
<div className={classNames("text-red-600 font-bold", className)}>
{children}
</div>
);
};
export default React.memo(ErrorMessage);
import React 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">
......@@ -11,80 +19,104 @@ const Footer = () => {
className="w-32 md:w-40 pb-6"
/>
<p className="para hidden md:block md:mb-4 lg:mb-0 text-grey-200">
Piráti, 2021. Všechna práva vyhlazena. Sdílejte a nechte ostatní
Piráti, 2024. Všechna práva vyhlazena. Sdílejte a nechte ostatní
sdílet za stejných podmínek.
</p>
</section>
<section className="footer__main-links bg-grey-700 text-white lg:grid grid-cols-3 gap-4"></section>
<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>
);
};
......
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={`https://a.pirati.cz/piratar/${user.username}.jpg`}
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>
......
import React from "react";
import classNames from "classnames";
const Thumbs = ({ likes, dislikes, onLike, onDislike, myVote }) => {
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-pointer": myVote === "none" || myVote === "like",
"cursor-default": myVote === "dislike",
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={myVote === "dislike"}
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-pointer": myVote === "none" || myVote === "dislike",
"cursor-default": myVote === "like",
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={myVote === "like"}
disabled={readOnly}
onClick={onDislike}
>
<i className="ico--thumbs-down transform -scale-x-1"></i>
......
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>
);
};
......
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 = (evt) => {
setText(evt.target.value);
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) => {
if (!!text) {
onConfirm(text);
evt.preventDefault();
let preventAction = false;
const payload = {
content: text,
};
if (!text) {
setTextError("Před úpravou oznámení nezapomeňte vyplnit jeho obsah.");
preventAction = true;
} else if (!!text && text.length > 1024) {
setTextError("Maximální délka oznámení je 1024 znaků.");
preventAction = true;
}
if (announcement.type === "voting" && !link) {
setLinkError("Zadejte platnou URL.");
preventAction = true;
} else {
payload.link = link;
}
if (preventAction) {
return;
}
onConfirm(payload);
};
return (
<Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}>
<Card>
<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}>
<i className="ico--close"></i>
<button onClick={onCancel} type="button">
<i className="ico--cross"></i>
</button>
</div>
<div className="form-field">
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control text-base"
<MarkdownEditor
value={text}
rows="8"
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"],
]}
/>
<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"
onClick={confirm}
loading={confirming}
disabled={textError || linkError || confirming}
type="submit"
>
Uložit
</Button>
......@@ -58,11 +140,13 @@ const AnnouncementEditModal = ({
color="red-600"
className="text-sm"
onClick={onCancel}
type="button"
>
Zrušit
</Button>
</CardActions>
</Card>
</form>
</Modal>
);
};
......
......@@ -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>
);
};
......
import React from "react";
import classNames from "classnames";
const Card = ({ children, elevation = 21, className }) => {
const cls = classNames("card", `elevation-${elevation}`, className);
return <div className={cls}>{children}</div>;
const Card = ({ children, className }, ref) => {
const cls = classNames("card", className);
return (
<div className={cls} ref={ref}>
{children}
</div>
);
};
export default Card;
export default React.forwardRef(Card);
import React from "react";
import classNames from "classnames";
const CardBody = ({ children, className }) => {
const CardBody = ({ children, className, ...props }) => {
const cls = classNames("card__body", className);
return <div className={cls}>{children}</div>;
return (
<div className={cls} {...props}>
{children}
</div>
);
};
export default CardBody;