Skip to content
Snippets Groups Projects

Compare revisions

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

Source

Select target project
No results found
Select Git revision
  • cf2023-euro
  • cf2023-offline
  • cf2024
  • cf2025
  • main
5 results

Target

Select target project
  • to/cf-online-ui
  • vpfafrin/cf2021
2 results
Select Git revision
  • master
1 result
Show changes
Showing
with 834 additions and 240 deletions
...@@ -2,18 +2,20 @@ import keyBy from "lodash/keyBy"; ...@@ -2,18 +2,20 @@ import keyBy from "lodash/keyBy";
import property from "lodash/property"; import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate"; import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api"; import { fetchApi } from "api";
import { AnnouncementStore } from "stores"; import { AnnouncementStore } from "stores";
import { import {
announcementTypeMappingRev, announcementTypeMappingRev,
createSeenWriter,
parseRawAnnouncement, parseRawAnnouncement,
seenAnnouncementsLSKey,
syncAnnoucementItemIds, syncAnnoucementItemIds,
} from "utils"; } from "utils";
export const loadAnnouncements = createAsyncAction( export const loadAnnouncements = createAsyncAction(
async () => { async () => {
try { try {
const resp = await fetch("/announcements"); const resp = await fetchApi("/announcements");
const data = await resp.json(); const data = await resp.json();
return successResult(data.data); return successResult(data.data);
} catch (err) { } catch (err) {
...@@ -31,7 +33,7 @@ export const loadAnnouncements = createAsyncAction( ...@@ -31,7 +33,7 @@ export const loadAnnouncements = createAsyncAction(
}); });
} }
}, },
} },
); );
/** /**
...@@ -45,13 +47,17 @@ export const addAnnouncement = createAsyncAction( ...@@ -45,13 +47,17 @@ export const addAnnouncement = createAsyncAction(
link, link,
type: announcementTypeMappingRev[type], type: announcementTypeMappingRev[type],
}); });
const resp = await fetch("/announcements", { method: "POST", body }); const resp = await fetchApi("/announcements", {
method: "POST",
body,
expectedStatus: 201,
});
const data = await resp.json(); const data = await resp.json();
return successResult(data.data); return successResult(data.data);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
} }
} },
); );
/** /**
...@@ -64,32 +70,51 @@ export const deleteAnnouncement = createAsyncAction( ...@@ -64,32 +70,51 @@ export const deleteAnnouncement = createAsyncAction(
*/ */
async (item) => { async (item) => {
try { try {
await fetch(`/announcements/${item.id}`, { method: "DELETE" }); await fetchApi(`/announcements/${item.id}`, {
method: "DELETE",
expectedStatus: 204,
});
return successResult({ item }); return successResult({ item });
} catch (err) { } catch (err) {
return errorResult([], err.toString()); 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 {CF2021.Announcement} item
* @param {string} newContent * @param {Object} payload
*/ */
async ({ item, newContent }) => { async ({ item, payload }) => {
try { try {
const body = JSON.stringify({ const body = JSON.stringify(payload);
content: newContent, await fetchApi(`/announcements/${item.id}`, {
method: "PUT",
body,
expectedStatus: 204,
}); });
await fetch(`/announcements/${item.id}`, { method: "PUT", body }); return successResult({ item, payload });
return successResult({ item, newContent });
} catch (err) { } catch (err) {
return errorResult([], err.toString()); 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);
});
}
},
},
);
...@@ -2,19 +2,21 @@ import keyBy from "lodash/keyBy"; ...@@ -2,19 +2,21 @@ import keyBy from "lodash/keyBy";
import property from "lodash/property"; import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate"; import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api"; import { fetchApi } from "api";
import { PostStore } from "stores"; import { AuthStore, PostStore } from "stores";
import { import {
createSeenWriter,
filterPosts, filterPosts,
parseRawPost, parseRawPost,
postsStateMappingRev, postsStateMappingRev,
postsTypeMappingRev, postsTypeMappingRev,
seenPostsLSKey,
} from "utils"; } from "utils";
export const loadPosts = createAsyncAction( export const loadPosts = createAsyncAction(
async () => { async () => {
try { try {
const resp = await fetch("/posts"); const resp = await fetchApi("/posts", { expectedStatus: 200 });
const data = await resp.json(); const data = await resp.json();
return successResult(data.data); return successResult(data.data);
} catch (err) { } catch (err) {
...@@ -23,7 +25,7 @@ export const loadPosts = createAsyncAction( ...@@ -23,7 +25,7 @@ export const loadPosts = createAsyncAction(
}, },
{ {
postActionHook: ({ result }) => { postActionHook: ({ result }) => {
if (!result.error) { if (!result.error && result.payload) {
const posts = result.payload.map(parseRawPost); const posts = result.payload.map(parseRawPost);
PostStore.update((state) => { PostStore.update((state) => {
...@@ -34,13 +36,11 @@ export const loadPosts = createAsyncAction( ...@@ -34,13 +36,11 @@ export const loadPosts = createAsyncAction(
state.window = { state.window = {
items: filteredPosts.map(property("id")), items: filteredPosts.map(property("id")),
itemCount: filteredPosts.length, itemCount: filteredPosts.length,
page: 1,
perPage: 20,
}; };
}); });
} }
}, },
} },
); );
export const like = createAsyncAction( export const like = createAsyncAction(
...@@ -49,7 +49,10 @@ export const like = createAsyncAction( ...@@ -49,7 +49,10 @@ export const like = createAsyncAction(
*/ */
async (post) => { async (post) => {
try { try {
await fetch(`/posts/${post.id}/like`, { method: "PATCH" }); await fetchApi(`/posts/${post.id}/like`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post); return successResult(post);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -59,11 +62,14 @@ export const like = createAsyncAction( ...@@ -59,11 +62,14 @@ export const like = createAsyncAction(
postActionHook: ({ result }) => { postActionHook: ({ result }) => {
if (!result.error) { if (!result.error) {
PostStore.update((state) => { PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote = "like"; state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "like"
? "like"
: "none";
}); });
} }
}, },
} },
); );
export const dislike = createAsyncAction( export const dislike = createAsyncAction(
...@@ -72,7 +78,10 @@ export const dislike = createAsyncAction( ...@@ -72,7 +78,10 @@ export const dislike = createAsyncAction(
*/ */
async (post) => { async (post) => {
try { try {
await fetch(`/posts/${post.id}/dislike`, { method: "PATCH" }); await fetchApi(`/posts/${post.id}/dislike`, {
method: "PATCH",
expectedStatus: 204,
});
return successResult(post); return successResult(post);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -82,11 +91,14 @@ export const dislike = createAsyncAction( ...@@ -82,11 +91,14 @@ export const dislike = createAsyncAction(
postActionHook: ({ result }) => { postActionHook: ({ result }) => {
if (!result.error) { if (!result.error) {
PostStore.update((state) => { PostStore.update((state) => {
state.items[result.payload.id].ranking.myVote = "dislike"; state.items[result.payload.id].ranking.myVote =
state.items[result.payload.id].ranking.myVote !== "dislike"
? "dislike"
: "none";
}); });
} }
}, },
} },
); );
/** /**
...@@ -98,7 +110,7 @@ export const addPost = createAsyncAction(async ({ content }) => { ...@@ -98,7 +110,7 @@ export const addPost = createAsyncAction(async ({ content }) => {
content, content,
type: postsTypeMappingRev["post"], type: postsTypeMappingRev["post"],
}); });
await fetch(`/posts`, { method: "POST", body }); await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult(); return successResult();
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -114,7 +126,7 @@ export const addProposal = createAsyncAction(async ({ content }) => { ...@@ -114,7 +126,7 @@ export const addProposal = createAsyncAction(async ({ content }) => {
content, content,
type: postsTypeMappingRev["procedure-proposal"], type: postsTypeMappingRev["procedure-proposal"],
}); });
await fetch(`/posts`, { method: "POST", body }); await fetchApi(`/posts`, { method: "POST", body, expectedStatus: 201 });
return successResult(); return successResult();
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -129,17 +141,16 @@ export const hide = createAsyncAction( ...@@ -129,17 +141,16 @@ export const hide = createAsyncAction(
* @param {CF2021.Post} post * @param {CF2021.Post} post
*/ */
async (post) => { async (post) => {
return successResult(post); try {
}, await fetchApi(`/posts/${post.id}`, {
{ method: "DELETE",
postActionHook: ({ result }) => { expectedStatus: 204,
if (!result.error) {
PostStore.update((state) => {
state.items[result.payload.id].hidden = true;
}); });
return successResult();
} catch (err) {
return errorResult([], err.toString());
} }
}, },
}
); );
/** /**
...@@ -154,12 +165,55 @@ export const edit = createAsyncAction( ...@@ -154,12 +165,55 @@ export const edit = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
content: newContent, content: newContent,
}); });
await fetch(`/posts/${post.id}`, { method: "PUT", body }); await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(); return successResult();
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
} }
},
{
shortCircuitHook: ({ args }) => {
const { user } = AuthStore.getRawState();
if (!user) {
return errorResult();
}
if (user && user.isBanned) {
return errorResult();
} }
return false;
},
},
);
/**
* Archive post.
*/
export const archive = createAsyncAction(
/**
* @param {CF2021.Post} post
*/
async (post) => {
try {
const body = JSON.stringify({
is_archived: true,
});
await fetchApi(`/posts/${post.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult();
} catch (err) {
return errorResult([], err.toString());
}
},
); );
/** /**
...@@ -167,11 +221,16 @@ export const edit = createAsyncAction( ...@@ -167,11 +221,16 @@ export const edit = createAsyncAction(
* @param {CF2021.ProposalPost} proposal * @param {CF2021.ProposalPost} proposal
* @param {CF2021.ProposalPostState} state * @param {CF2021.ProposalPostState} state
*/ */
const updateProposalState = async (proposal, state) => { const updateProposalState = async (proposal, state, additionalPayload) => {
const body = JSON.stringify({ const body = JSON.stringify({
state: postsStateMappingRev[state], state: postsStateMappingRev[state],
...(additionalPayload || {}),
});
await fetchApi(`/posts/${proposal.id}`, {
method: "PUT",
body,
expectedStatus: 204,
}); });
await fetch(`/posts/${proposal.id}`, { method: "PUT", body });
return successResult(proposal); return successResult(proposal);
}; };
...@@ -197,7 +256,7 @@ export const announceProposal = createAsyncAction( ...@@ -197,7 +256,7 @@ export const announceProposal = createAsyncAction(
return false; return false;
}, },
} },
); );
/** /**
...@@ -207,22 +266,22 @@ export const acceptProposal = createAsyncAction( ...@@ -207,22 +266,22 @@ export const acceptProposal = createAsyncAction(
/** /**
* @param {CF2021.ProposalPost} proposal * @param {CF2021.ProposalPost} proposal
*/ */
(proposal) => { ({ proposal, archive }) => {
return updateProposalState(proposal, "accepted"); return updateProposalState(proposal, "accepted", { is_archived: archive });
}, },
{ {
shortCircuitHook: ({ args }) => { shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") { if (args.proposal.type !== "procedure-proposal") {
return errorResult(); return errorResult();
} }
if (args.state !== "announced") { if (args.proposal.state !== "announced") {
return errorResult(); return errorResult();
} }
return false; return false;
}, },
} },
); );
/** /**
...@@ -232,22 +291,22 @@ export const rejectProposal = createAsyncAction( ...@@ -232,22 +291,22 @@ export const rejectProposal = createAsyncAction(
/** /**
* @param {CF2021.ProposalPost} proposal * @param {CF2021.ProposalPost} proposal
*/ */
(proposal) => { ({ proposal, archive }) => {
return updateProposalState(proposal, "rejected"); return updateProposalState(proposal, "rejected", { is_archived: archive });
}, },
{ {
shortCircuitHook: ({ args }) => { shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") { if (args.proposal.type !== "procedure-proposal") {
return errorResult(); return errorResult();
} }
if (args.state !== "announced") { if (args.proposal.state !== "announced") {
return errorResult(); return errorResult();
} }
return false; return false;
}, },
} },
); );
/** /**
...@@ -257,20 +316,36 @@ export const rejectProposalByChairman = createAsyncAction( ...@@ -257,20 +316,36 @@ export const rejectProposalByChairman = createAsyncAction(
/** /**
* @param {CF2021.ProposalPost} proposal * @param {CF2021.ProposalPost} proposal
*/ */
(proposal) => { ({ proposal, archive }) => {
return updateProposalState(proposal, "rejected-by-chairman"); return updateProposalState(proposal, "rejected-by-chairman", {
is_archived: archive,
});
}, },
{ {
shortCircuitHook: ({ args }) => { shortCircuitHook: ({ args }) => {
if (args.type !== "procedure-proposal") { if (args.proposal.type !== "procedure-proposal") {
return errorResult(); return errorResult();
} }
if (!["pending", "announced"].includes(args.state)) { if (!["pending", "announced"].includes(args.proposal.state)) {
return errorResult(); return errorResult();
} }
return false; 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 keyBy from "lodash/keyBy";
import pick from "lodash/pick"; import pick from "lodash/pick";
import property from "lodash/property"; import property from "lodash/property";
import { createAsyncAction, errorResult, successResult } from "pullstate"; import { createAsyncAction, errorResult, successResult } from "pullstate";
import { fetch } from "api"; import { fetchApi } from "api";
import { markdownConverter } from "markdown";
import { ProgramStore } from "stores"; import { ProgramStore } from "stores";
import { loadPosts } from "./posts"; import { loadPosts } from "./posts";
...@@ -11,7 +13,7 @@ import { loadPosts } from "./posts"; ...@@ -11,7 +13,7 @@ import { loadPosts } from "./posts";
export const loadProgram = createAsyncAction( export const loadProgram = createAsyncAction(
async () => { async () => {
try { try {
const resp = await fetch("/program"); const resp = await fetchApi("/program");
const mappings = await resp.json(); const mappings = await resp.json();
return successResult(mappings); return successResult(mappings);
} catch (err) { } catch (err) {
...@@ -36,14 +38,28 @@ export const loadProgram = createAsyncAction( ...@@ -36,14 +38,28 @@ export const loadProgram = createAsyncAction(
"title", "title",
"description", "description",
"proposer", "proposer",
"speakers",
]), ]),
fullTitle:
entry.number !== ""
? `${entry.number}. ${entry.title}`
: entry.title,
htmlContent: markdownConverter.makeHtml(entry.description),
discussionOpened: entry.discussion_opened, discussionOpened: entry.discussion_opened,
expectedStartAt: new Date(entry.expected_start_at), expectedStartAt: parse(
entry.expected_start_at,
"yyyy-MM-dd HH:mm:ss",
new Date(),
),
expectedFinishAt: entry.expected_finish_at 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, : undefined,
}; };
} },
) )
.sort((a, b) => a.expectedStartAt - b.expectedStartAt); .sort((a, b) => a.expectedStartAt - b.expectedStartAt);
...@@ -59,7 +75,7 @@ export const loadProgram = createAsyncAction( ...@@ -59,7 +75,7 @@ export const loadProgram = createAsyncAction(
}); });
} }
}, },
} },
); );
/** /**
...@@ -71,7 +87,11 @@ export const renameProgramPoint = createAsyncAction( ...@@ -71,7 +87,11 @@ export const renameProgramPoint = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
title: newTitle, title: newTitle,
}); });
await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult({ programEntry, newTitle }); return successResult({ programEntry, newTitle });
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -88,7 +108,7 @@ export const renameProgramPoint = createAsyncAction( ...@@ -88,7 +108,7 @@ export const renameProgramPoint = createAsyncAction(
}); });
} }
}, },
} },
); );
/** /**
...@@ -104,7 +124,11 @@ export const endProgramPoint = createAsyncAction( ...@@ -104,7 +124,11 @@ export const endProgramPoint = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
is_live: false, is_live: false,
}); });
await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry); return successResult(programEntry);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -118,7 +142,7 @@ export const endProgramPoint = createAsyncAction( ...@@ -118,7 +142,7 @@ export const endProgramPoint = createAsyncAction(
}); });
} }
}, },
} },
); );
/** /**
...@@ -134,7 +158,11 @@ export const activateProgramPoint = createAsyncAction( ...@@ -134,7 +158,11 @@ export const activateProgramPoint = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
is_live: true, is_live: true,
}); });
await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry); return successResult(programEntry);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -151,7 +179,7 @@ export const activateProgramPoint = createAsyncAction( ...@@ -151,7 +179,7 @@ export const activateProgramPoint = createAsyncAction(
loadPosts.run({}, { respectCache: false }); loadPosts.run({}, { respectCache: false });
} }
}, },
} },
); );
/** /**
...@@ -167,7 +195,11 @@ export const openDiscussion = createAsyncAction( ...@@ -167,7 +195,11 @@ export const openDiscussion = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
discussion_opened: true, discussion_opened: true,
}); });
await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry); return successResult(programEntry);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -183,7 +215,7 @@ export const openDiscussion = createAsyncAction( ...@@ -183,7 +215,7 @@ export const openDiscussion = createAsyncAction(
}); });
} }
}, },
} },
); );
/** /**
...@@ -195,7 +227,11 @@ export const closeDiscussion = createAsyncAction( ...@@ -195,7 +227,11 @@ export const closeDiscussion = createAsyncAction(
const body = JSON.stringify({ const body = JSON.stringify({
discussion_opened: false, discussion_opened: false,
}); });
await fetch(`/program/${programEntry.id}`, { method: "PUT", body }); await fetchApi(`/program/${programEntry.id}`, {
method: "PUT",
body,
expectedStatus: 204,
});
return successResult(programEntry); return successResult(programEntry);
} catch (err) { } catch (err) {
return errorResult([], err.toString()); return errorResult([], err.toString());
...@@ -211,5 +247,5 @@ export const closeDiscussion = createAsyncAction( ...@@ -211,5 +247,5 @@ export const closeDiscussion = createAsyncAction(
}); });
} }
}, },
} },
); );
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( export const ban = createAsyncAction(
/** /**
* @param {number} userId * @param {number} userId
*/ */
async (userId) => { async (user) => {
return successResult(userId); 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 { createAsyncAction, errorResult, successResult } from "pullstate";
import { GlobalInfoStore } from "stores";
import { connect } from "ws/connection"; import { connect } from "ws/connection";
import { loadAnnouncements } from "./announcements"; import { loadAnnouncements } from "./announcements";
...@@ -7,8 +8,11 @@ import { loadPosts } from "./posts"; ...@@ -7,8 +8,11 @@ import { loadPosts } from "./posts";
import { loadProgram } from "./program"; import { loadProgram } from "./program";
export const initializeWSChannel = createAsyncAction(async () => { export const initializeWSChannel = createAsyncAction(async () => {
const { websocketUrl } = GlobalInfoStore.getRawState();
try { try {
const wsChannel = await connect({ const wsChannel = await connect({
url: websocketUrl,
onConnect: async ({ worker }) => { onConnect: async ({ worker }) => {
// Re-load initial data once connected, this will ensure we won't lose // Re-load initial data once connected, this will ensure we won't lose
// any intermediate state. // any intermediate state.
......
import baseFetch from "unfetch";
import { AuthStore } from "./stores"; 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(); const { isAuthenticated, user } = AuthStore.getRawState();
opts = opts || {};
opts.headers = opts.headers || {};
if (isAuthenticated) { if (isAuthenticated) {
opts.headers.Authorization = "Bearer " + user.accessToken; headers.Authorization = "Bearer " + user.accessToken;
} }
if (!opts.headers["Content-Type"]) { if (!headers["Content-Type"]) {
opts.headers["Content-Type"] = "application/json"; 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,15 +4,15 @@ import classNames from "classnames"; ...@@ -4,15 +4,15 @@ import classNames from "classnames";
const Button = ({ const Button = ({
className, className,
iconWrapperClassName, bodyClassName,
icon, icon,
color = "black", color = "black",
iconChildren = null,
hoverActive = true, hoverActive = true,
fullwidth = false, fullwidth = false,
loading = false, loading = false,
children, children,
routerTo, routerTo,
bodyProps = {},
...props ...props
}) => { }) => {
const btnClass = classNames( const btnClass = classNames(
...@@ -27,15 +27,16 @@ const Button = ({ ...@@ -27,15 +27,16 @@ const Button = ({
className className
); );
const iconWrapperClass = classNames("btn__icon", iconWrapperClassName); const bodyClass = classNames("btn__body", bodyClassName);
const inner = ( const inner = (
<div className="btn__body-wrap"> <div className="btn__body-wrap">
<div className="btn__body">{children}</div> <div className={bodyClass} {...bodyProps}>
{children}
</div>
{!!icon && ( {!!icon && (
<div className={iconWrapperClass}> <div className="btn__icon">
<i className={icon}></i> <i className={icon}></i>
{iconChildren}
</div> </div>
)} )}
</div> </div>
...@@ -56,4 +57,4 @@ const Button = ({ ...@@ -56,4 +57,4 @@ const Button = ({
); );
}; };
export default Button; export default React.memo(Button);
...@@ -26,4 +26,4 @@ const Chip = ({ ...@@ -26,4 +26,4 @@ const Chip = ({
); );
}; };
export default Chip; export default React.memo(Chip);
...@@ -25,4 +25,4 @@ const Dropdown = ({ value, options, onChange, className }) => { ...@@ -25,4 +25,4 @@ const Dropdown = ({ value, options, onChange, className }) => {
); );
}; };
export default Dropdown; export default React.memo(Dropdown);
...@@ -9,4 +9,4 @@ const ErrorMessage = ({ className, children }) => { ...@@ -9,4 +9,4 @@ const ErrorMessage = ({ className, children }) => {
); );
}; };
export default ErrorMessage; export default React.memo(ErrorMessage);
import React from "react"; import React, { useState } from "react";
import { NavLink } from "react-router-dom";
import useWindowSize from "@rooks/use-window-size";
import classNames from "classnames";
const Footer = () => { const Footer = () => {
const { innerWidth } = useWindowSize();
const [showCfMenu, setShowCfMenu] = useState(false);
const [showOtherMenu, setShowOtherMenu] = useState(false);
const isLg = innerWidth >= 1024;
return ( return (
<footer className="footer bg-grey-700 text-white"> <footer className="footer bg-grey-700 text-white">
<div className="footer__main py-4 lg:py-16 container container--default"> <div className="footer__main py-4 lg:py-16 container container--default">
...@@ -11,80 +19,104 @@ const Footer = () => { ...@@ -11,80 +19,104 @@ const Footer = () => {
className="w-32 md:w-40 pb-6" className="w-32 md:w-40 pb-6"
/> />
<p className="para hidden md:block md:mb-4 lg:mb-0 text-grey-200"> <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. sdílet za stejných podmínek.
</p> </p>
</section> </section>
<section className="footer__main-links bg-grey-700 text-white lg:grid grid-cols-3 gap-4"></section> <section className="footer__main-links bg-grey-700 text-white lg:grid grid-cols-2 gap-4">
<section className="footer__social lg:text-right"> <div className="pt-8 pb-4 lg:py-0">
<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"> <div className="footer-collapsible">
<button className="btn btn--icon btn--blue-300 btn--hoveractive text-lg btn--fullwidth sm:btn--autowidth"> <span
<div className="btn__body-wrap"> className={classNames(
<div className="btn__body ">Pomoz nám</div>{" "} "text-xl uppercase text-white footer-collapsible__toggle",
<div className="btn__icon "> {
<i className="ico--anchor"></i> "footer-collapsible__toggle--open": showCfMenu,
</div> }
</div> )}
</button> onClick={() => setShowCfMenu(!showCfMenu)}
>
CF 2024
</span>{" "}
<div className={showCfMenu || isLg ? "" : "hidden"}>
<ul className="mt-6 space-y-2 text-grey-200">
<li>
<NavLink to="/">Přímý přenos</NavLink>
</li>
<li>
<NavLink to="/program">Program</NavLink>
</li>
<li>
<NavLink to="/protocol">Zápis</NavLink>
</li>
<li>
<NavLink to="/about">Co je to celostátní fórum?</NavLink>
</li>
</ul>
</div> </div>
</section>
</div> </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>
<div className="badge__body"> <div className="py-4 lg:py-0 border-t border-grey-400 lg:border-t-0">
<span className="head-heavy-2xs badge__title"> <div className="footer-collapsible">
Andrej Ramašeuski <span
</span> className={classNames(
<p className="badge__occupation">Kontakt pro dobrovolníky</p> "text-xl uppercase text-white footer-collapsible__toggle",
<a {
href="mailto:example@example.com" "footer-collapsible__toggle--open": showOtherMenu,
className="contact-line contact-line--responsive icon-link badge__link" }
)}
onClick={() => setShowOtherMenu(!showOtherMenu)}
> >
<i className="ico--phone"></i> Otevřenost
<span>+420 777 123 123</span> </span>{" "}
</a> <div className={showOtherMenu || isLg ? "" : "hidden"}>
<a <ul className="mt-6 space-y-2 text-grey-200">
href="mailto:example@example.com" <li>
className="contact-line contact-line--responsive icon-link badge__link" <a href="https://ucet.pirati.cz">Transparentní účet</a>
> </li>{" "}
<i className="ico--envelope"></i> <li>
<span>example@example.com</span> <a href="https://smlouvy.pirati.cz">Registr smluv</a>
</li>{" "}
<li>
<a href="https://wiki.pirati.cz/fo/otevrene_ucetnictvi">
Otevřené účetnictví
</a> </a>
</li>
</ul>
</div> </div>
</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>
<div className="badge__body"> </section>
<span className="head-heavy-2xs badge__title"> <section className="footer__social lg:text-right">
Andrea Linhartová <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">
</span>
<p className="badge__occupation">Kontakt pro média</p>
<a <a
href="mailto:example@example.com" href="https://dary.pirati.cz"
className="contact-line contact-line--responsive icon-link badge__link" 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> <div className="btn__body-wrap">
<span>+420 777 123 123</span> <div className="btn__body ">Přispěj</div>{" "}
</a> <div className="btn__icon ">
<i className="ico--pig"></i>
</div>
</div>
</a>{" "}
<a <a
href="mailto:example@example.com" href="https://nalodeni.pirati.cz"
className="contact-line contact-line--responsive icon-link badge__link" 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> <div className="btn__body-wrap">
<span>example@example.com</span> <div className="btn__body ">Naloď se</div>{" "}
</a> <div className="btn__icon ">
</div> <i className="ico--anchor"></i>
</div> </div>
</div> </div>
</a>
</div> </div>
</section> </section>
</div>
</footer> </footer>
); );
}; };
......
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from "react";
import { isBrowser } from "react-device-detect";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import { useKeycloak } from "@react-keycloak/web"; import { useKeycloak } from "@react-keycloak/web";
import useWindowSize from "@rooks/use-window-size";
import classNames from "classnames";
import Button from "components/Button"; import Button from "components/Button";
import { AuthStore } from "stores"; import { AuthStore, GlobalInfoStore } from "stores";
const Navbar = () => { const Navbar = ({ onGetHelp }) => {
const [showMenu, setShowMenu] = useState(isBrowser); const { innerWidth } = useWindowSize();
const [showMenu, setShowMenu] = useState();
const { keycloak } = useKeycloak(); const { keycloak } = useKeycloak();
const { isAuthenticated, user } = AuthStore.useState(); const { isAuthenticated, user } = AuthStore.useState();
const { connectionState } = GlobalInfoStore.useState();
const login = useCallback(() => { const login = useCallback(() => {
keycloak.login(); keycloak.login();
...@@ -18,6 +21,47 @@ const Navbar = () => { ...@@ -18,6 +21,47 @@ const Navbar = () => {
keycloak.logout(); keycloak.logout();
}, [keycloak]); }, [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 ( return (
<nav className="navbar navbar--simple"> <nav className="navbar navbar--simple">
<div className="container container--wide navbar__content navbar__content--initialized"> <div className="container container--wide navbar__content navbar__content--initialized">
...@@ -33,7 +77,7 @@ const Navbar = () => { ...@@ -33,7 +77,7 @@ const Navbar = () => {
to="/" to="/"
className="pl-4 font-bold text-xl lg:border-r lg:border-grey-300 lg:pr-8 hover:no-underline" 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> </NavLink>
</div> </div>
<div className="navbar__menutoggle my-4 flex justify-end lg:hidden"> <div className="navbar__menutoggle my-4 flex justify-end lg:hidden">
...@@ -44,7 +88,7 @@ const Navbar = () => { ...@@ -44,7 +88,7 @@ const Navbar = () => {
<i className="ico--menu text-3xl"></i> <i className="ico--menu text-3xl"></i>
</button> </button>
</div> </div>
{showMenu && ( {(showMenu || isLg) && (
<> <>
<div className="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto"> <div className="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto">
<ul className="navbar-menu text-white"> <ul className="navbar-menu text-white">
...@@ -58,24 +102,38 @@ const Navbar = () => { ...@@ -58,24 +102,38 @@ const Navbar = () => {
Program Program
</NavLink> </NavLink>
</li> </li>
<li className="navbar-menu__item">
<NavLink className="navbar-menu__link" to="/protocol">
Zápis
</NavLink>
</li>
</ul> </ul>
</div> </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 && ( {!isAuthenticated && (
<Button className="btn--white" onClick={login}> <Button className="btn--white joyride-login" onClick={login}>
Přihlásit se Přihlásit se
</Button> </Button>
)} )}
{isAuthenticated && ( {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> <span className="head-heavy-2xs">{user.name}</span>
<div className="avatar avatar--2xs"> <div className="avatar avatar--2xs">
<img <img
src={`https://a.pirati.cz/piratar/${user.username}.jpg`} src={`https://a.pirati.cz/piratar/200/${user.username}.jpg`}
alt="Avatar" alt="Avatar"
/> />
</div> </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> <i className="ico--log-out"></i>
</button> </button>
</div> </div>
......
...@@ -8,9 +8,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => { ...@@ -8,9 +8,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => {
<button <button
className={classNames("flex items-center space-x-1", { className={classNames("flex items-center space-x-1", {
"cursor-pointer": !readOnly, "cursor-pointer": !readOnly,
"cursor-default": readOnly, "cursor-not-allowed": readOnly,
"text-blue-300": myVote === "like", "text-blue-300": myVote === "like",
"text-grey-200 hover:text-blue-300": myVote !== "like", "text-grey-200 ": myVote !== "like",
"hover:text-blue-300": myVote !== "like" && !readOnly,
})} })}
disabled={readOnly} disabled={readOnly}
onClick={onLike} onClick={onLike}
...@@ -21,9 +22,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => { ...@@ -21,9 +22,10 @@ const Thumbs = ({ likes, dislikes, myVote, onLike, onDislike, readOnly }) => {
<button <button
className={classNames("flex items-center space-x-1", { className={classNames("flex items-center space-x-1", {
"cursor-pointer": !readOnly, "cursor-pointer": !readOnly,
"cursor-default": readOnly, "cursor-not-allowed": readOnly,
"text-red-600": myVote === "dislike", "text-red-600": myVote === "dislike",
"text-grey-200 hover:text-red-600": myVote !== "dislike", "text-grey-200": myVote !== "dislike",
"hover:text-red-600": myVote !== "dislike" && !readOnly,
})} })}
disabled={readOnly} disabled={readOnly}
onClick={onDislike} onClick={onDislike}
......
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useInView } from "react-intersection-observer"; import { useInView } from "react-intersection-observer";
import classNames from "classnames"; import classNames from "classnames";
import { format } from "date-fns"; import { format, isToday } from "date-fns";
import Chip from "components/Chip"; import Chip from "components/Chip";
import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu"; import { DropdownMenu, DropdownMenuItem } from "components/dropdown-menu";
...@@ -20,7 +20,7 @@ const Announcement = ({ ...@@ -20,7 +20,7 @@ const Announcement = ({
onSeen, onSeen,
}) => { }) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
threshold: 1, threshold: 0.8,
trackVisibility: true, trackVisibility: true,
delay: 1500, delay: 1500,
skip: seen, skip: seen,
...@@ -59,7 +59,7 @@ const Announcement = ({ ...@@ -59,7 +59,7 @@ const Announcement = ({
const chipLabel = { const chipLabel = {
"rejected-procedure-proposal": "Zamítnutý návrh postupu", "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", "accepted-procedure-proposal": "Schválený návrh postupu",
voting: "Rozhodující hlasování", voting: "Rozhodující hlasování",
announcement: "Oznámení předsedajícího", announcement: "Oznámení předsedajícího",
...@@ -75,11 +75,17 @@ const Announcement = ({ ...@@ -75,11 +75,17 @@ const Announcement = ({
"announcement", "announcement",
].includes(type); ].includes(type);
const htmlContent = {
__html: content,
};
return ( return (
<div className={wrapperClassName} ref={ref}> <div className={wrapperClassName} ref={ref}>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="space-x-2 flex items-center"> <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> <Chip color={chipColor} condensed>
{chipLabel} {chipLabel}
</Chip> </Chip>
...@@ -95,23 +101,30 @@ const Announcement = ({ ...@@ -95,23 +101,30 @@ const Announcement = ({
)} )}
</div> </div>
{canRunActions && ( {canRunActions && (
<DropdownMenu right triggerIconClass="ico--dots-three-horizontal"> <DropdownMenu
right
className="pl-4"
triggerIconClass="ico--dots-three-horizontal"
>
{showEdit && ( {showEdit && (
<DropdownMenuItem <DropdownMenuItem
onClick={onEdit} onClick={onEdit}
icon="ico--edit-pencil" icon="ico--pencil"
title="Upravit" title="Upravit"
/> />
)} )}
<DropdownMenuItem <DropdownMenuItem
onClick={onDelete} onClick={onDelete}
icon="ico--trashcan" icon="ico--bin"
title="Smazat" title="Smazat"
/> />
</DropdownMenu> </DropdownMenu>
)} )}
</div> </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> </div>
); );
}; };
......
import React, { useState } from "react"; import React, { useState } from "react";
import classNames from "classnames";
import Button from "components/Button"; import Button from "components/Button";
import { Card, CardActions, CardBody, CardHeadline } from "components/cards"; 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 Modal from "components/modals/Modal";
import { urlRegex } from "utils";
const AnnouncementEditModal = ({ const AnnouncementEditModal = ({
announcement, announcement,
onCancel, onCancel,
onConfirm, onConfirm,
confirming,
error,
...props ...props
}) => { }) => {
const [text, setText] = useState(announcement.content); 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) => { const onTextInput = (newText) => {
setText(evt.target.value); 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) => { const confirm = (evt) => {
if (!!text) { evt.preventDefault();
onConfirm(text);
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 ( return (
<Modal containerClassName="max-w-md" onRequestClose={onCancel} {...props}> <Modal containerClassName="max-w-lg" onRequestClose={onCancel} {...props}>
<Card> <form onSubmit={confirm}>
<Card className="elevation-21">
<CardBody> <CardBody>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<CardHeadline>Upravit oznámení</CardHeadline> <CardHeadline>Upravit oznámení</CardHeadline>
<button onClick={onCancel}> <button onClick={onCancel} type="button">
<i className="ico--close"></i> <i className="ico--cross"></i>
</button> </button>
</div> </div>
<div className="form-field"> <MarkdownEditor
<label className="form-field__label" htmlFor="field">
Nový text oznámení
</label>
<div className="form-field__wrapper form-field__wrapper--shadowed">
<textarea
className="text-input form-field__control text-base"
value={text} value={text}
rows="8"
placeholder="Vyplňte text oznámení"
onChange={onTextInput} 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> </div>
{!!linkError && (
<div className="form-field__error">{linkError}</div>
)}
</div> </div>
{error && (
<ErrorMessage className="mt-2">
Při editaci došlo k problému: {error}
</ErrorMessage>
)}
</CardBody> </CardBody>
<CardActions right className="space-x-1"> <CardActions right className="space-x-1">
<Button <Button
hoverActive hoverActive
color="blue-300" color="blue-300"
className="text-sm" className="text-sm"
onClick={confirm} loading={confirming}
disabled={textError || linkError || confirming}
type="submit"
> >
Uložit Uložit
</Button> </Button>
...@@ -61,11 +140,13 @@ const AnnouncementEditModal = ({ ...@@ -61,11 +140,13 @@ const AnnouncementEditModal = ({
color="red-600" color="red-600"
className="text-sm" className="text-sm"
onClick={onCancel} onClick={onCancel}
type="button"
> >
Zrušit Zrušit
</Button> </Button>
</CardActions> </CardActions>
</Card> </Card>
</form>
</Modal> </Modal>
); );
}; };
......
...@@ -23,14 +23,27 @@ const AnnouncementList = ({ ...@@ -23,14 +23,27 @@ const AnnouncementList = ({
onSeen(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 ( return (
<div className={classNames("space-y-px", className)}> <div className={classNames("space-y-px", className)}>
{items.map((item) => ( {items.map((item, idx) => (
<Announcement <Announcement
className={getClassName(idx)}
key={item.id} key={item.id}
datetime={item.datetime} datetime={item.datetime}
type={item.type} type={item.type}
content={item.content} content={item.contentHtml}
link={item.link} link={item.link}
seen={item.seen} seen={item.seen}
canRunActions={canRunActions} canRunActions={canRunActions}
...@@ -39,6 +52,11 @@ const AnnouncementList = ({ ...@@ -39,6 +52,11 @@ const AnnouncementList = ({
onSeen={onAnnouncementSeen(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> </div>
); );
}; };
......
import React from "react"; import React from "react";
import classNames from "classnames"; import classNames from "classnames";
const Card = ({ children, elevation = 21, className }) => { const Card = ({ children, className }, ref) => {
const cls = classNames("card", `elevation-${elevation}`, className); const cls = classNames("card", className);
return <div className={cls}>{children}</div>; return (
<div className={cls} ref={ref}>
{children}
</div>
);
}; };
export default Card; export default React.forwardRef(Card);
import React from "react"; import React from "react";
import classNames from "classnames"; import classNames from "classnames";
const CardBody = ({ children, className }) => { const CardBody = ({ children, className, ...props }) => {
const cls = classNames("card__body", className); const cls = classNames("card__body", className);
return <div className={cls}>{children}</div>; return (
<div className={cls} {...props}>
{children}
</div>
);
}; };
export default CardBody; export default CardBody;
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;