diff --git a/backend/answers/urls.py b/backend/answers/urls.py index 5d17142237de48cff15367fdcdf49e8566207549..263874484ccdc43d16029b83b492797ddaa38ca4 100644 --- a/backend/answers/urls.py +++ b/backend/answers/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ path('answersection/<int:oid>/', views_cuts.get_answersection, name='answersection'), path('metadata/<str:filename>/', views.exam_metadata, name='metadata'), path('setmetadata/<str:filename>/', views.exam_set_metadata, name='setmetadata'), + path('answer/<str:long_id>/', views_answers.get_answer, name='getanswer'), path('setanswer/<int:oid>/', views_answers.set_answer, name='setanswer'), path('removeanswer/<int:oid>/', views_answers.remove_answer, name='removeanswer'), path('setlike/<int:oid>/', views_answers.set_like, name='setlike'), diff --git a/backend/answers/views_answers.py b/backend/answers/views_answers.py index b8917aa388656a5d29f79833bea184371a97d4fa..95bebf992bc39c3920a72f1ffb0c5e9b17fc6cc1 100644 --- a/backend/answers/views_answers.py +++ b/backend/answers/views_answers.py @@ -4,9 +4,20 @@ from answers.models import AnswerSection, Answer from answers import section_util from notifications import notification_util from django.shortcuts import get_object_or_404 +from django.http import Http404 from django.utils import timezone +@auth_check.require_login +def get_answer(request, long_id): + try: + answer = Answer.objects.get(long_id=long_id) + return response.success(value=section_util.get_answer_response(request, answer)) + except Answer.DoesNotExist as e: + return Http404() + except Answer.MultipleObjectsReturned as e: + return Http404() + @response.request_post('text', 'legacy_answer') @auth_check.require_login def set_answer(request, oid): diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 9d51db2605b8d70261ba19023955c6c1e1793161..09a2bca37c0743b32d6343c342fe88fc3b197058 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -58,8 +58,10 @@ if DEBUG: else: allowed = ['https://{}/static/'.format(host) for host in REAL_ALLOWED_HOSTS] CSP_SCRIPT_SRC = ("'unsafe-eval'", *allowed) -CSP_STYLE_SRC = ("'self'", "'unsafe-inline'") -CSP_IMG_SRC = ("'self'", "https://static.vis.ethz.ch") +CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://static.vseth.ethz.ch") +CSP_FONT_SRC = ("'self'", "https://fonts.gstatic.com") +CSP_CONNECT_SRC = ("'self'", "https://static.vseth.ethz.ch") +CSP_IMG_SRC = ("'self'", "data:", "https://static.vis.ethz.ch", "https://static.vseth.ethz.ch") # Application definition diff --git a/backend/frontend/views.py b/backend/frontend/views.py index 5c6621fa2809334ae0e1a9d666808243316d45c8..c13d39436d37416c63f700ef9015cf08baa44145 100644 --- a/backend/frontend/views.py +++ b/backend/frontend/views.py @@ -3,11 +3,17 @@ from answers.models import Exam from django.shortcuts import get_object_or_404, redirect from django.http import HttpResponse, Http404 from django.views.decorators.csrf import ensure_csrf_cookie +import json @ensure_csrf_cookie def index(request): - return response.send_file('index.html') + with open('index.html') as f: + html = f.read() + html = html.replace('__SERVER_DATA__', json.dumps({ + 'org_id': '0000' + })) + return HttpResponse(html, content_type='text/html', charset='utf-8') def favicon(request): diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index c51b0690d64e56c39e0712c86a22c40702e02853..924d76efa0c454632eec26cb8cf3de56c5465c3b 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -11,8 +11,14 @@ "functions": "always-multiline" } ], + "prefer-arrow-callback": "warn", + "template-curly-spacing": "error", + "prefer-template": "error", + "react/no-typos": "error", + "object-shorthand": "error", "prefer-const": "error", "no-new": "error", + "react/self-closing-comp": "error", "guard-for-in": "error" } } diff --git a/frontend/package.json b/frontend/package.json index ff501b7b9bf8f3976cfb8bf4e1120e611e89a71f..a832eee2d9602c0d2f6f7f7cd60771e1d6d23d85 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,8 @@ "private": true, "proxy": "http://localhost:8080", "dependencies": { + "@emotion/core": "^10.0.28", + "@emotion/styled": "^10.0.27", "@matejmazur/react-katex": "^3.0.2", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", @@ -16,8 +18,10 @@ "@types/react-dom": "^16.9.0", "@types/react-router-dom": "^5.1.3", "@types/react-syntax-highlighter": "^10.1.0", + "@umijs/hooks": "^1.8.0", + "@vseth/components": "^1.5.0-alpha.23", + "@vseth/vseth-theme": "^1.5.0-alpha.9", "emotion": "^10.0.27", - "glamor": "^2.20.40", "katex": "^0.10.0", "lodash": "^4.17.5", "moment": "^2.22.2", @@ -30,13 +34,14 @@ "react-router-dom": "^5.1.2", "react-scripts": "3.4.0", "react-syntax-highlighter": "^10.2.1", + "react-transition-group": "^4.3.0", "remark-math": "^1.0.5", "typescript": "~3.7.2", "worker-loader": "^2.0.0" }, "scripts": { "start": "react-scripts start", - "build": "react-scripts build", + "build": "react-scripts --expose-gc --max-old-space-size=8192 build", "test": "react-scripts test", "eject": "react-scripts eject", "format": "prettier --write src/**/*.{js,jsx,ts,tsx,json,css}", diff --git a/frontend/public/index.html b/frontend/public/index.html index 03f257305086edac2c44d953b0c8ef688f8ea362..2d1591c8fb256fc2d9c1a926160e2123a332c168 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,20 +1,28 @@ <!DOCTYPE html> <html lang="en"> - -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> - <meta name="theme-color" content="#000000"> - <link rel="manifest" href="%PUBLIC_URL%/manifest.json"> - <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico"> + <head> + <meta charset="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1, shrink-to-fit=no" + /> + <meta name="theme-color" content="#000000" /> + <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> + <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" /> + <link + rel="stylesheet" + href="https://static.vseth.ethz.ch/npm/%40vseth/vseth-theme%40latest/dist/vseth-bootstrap-theme.css" + /> + <script type="application/json" id="server_data"> + __SERVER_DATA__ + </script> <title>VIS Community Solutions</title> -</head> - -<body> -<noscript> - You need to enable JavaScript to run this app. -</noscript> -<div id="root"></div> -</body> + </head> -</html> \ No newline at end of file + <body> + <noscript> + You need to enable JavaScript to run this app. + </noscript> + <div id="root"></div> + </body> +</html> diff --git a/frontend/src/api/comment.ts b/frontend/src/api/comment.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cb2c47f337fe861e6d823a22f278dc870f24157 --- /dev/null +++ b/frontend/src/api/comment.ts @@ -0,0 +1,21 @@ +import { AnswerSection } from "../interfaces"; +import { fetchPost } from "./fetch-utils"; + +export const addNewComment = async (answerId: string, text: string) => { + return ( + await fetchPost(`/api/exam/addcomment/${answerId}/`, { + text, + }) + ).value as AnswerSection; +}; +export const updateComment = async (commentId: string, text: string) => { + return ( + await fetchPost(`/api/exam/setcomment/${commentId}/`, { + text, + }) + ).value as AnswerSection; +}; +export const removeComment = async (commentId: string) => { + return (await fetchPost(`/api/exam/removecomment/${commentId}/`, {})) + .value as AnswerSection; +}; diff --git a/frontend/src/exam-loader.ts b/frontend/src/api/exam-loader.ts similarity index 76% rename from frontend/src/exam-loader.ts rename to frontend/src/api/exam-loader.ts index 2cd42d4f0e596c62a4417fe67e3b178c79288874..cebaf3b4f08316ff83c7f9973fda4065698e109d 100644 --- a/frontend/src/exam-loader.ts +++ b/frontend/src/api/exam-loader.ts @@ -1,4 +1,10 @@ -import { Section, AnswerSection, SectionKind, PdfSection } from "./interfaces"; +import { + AnswerSection, + PdfSection, + Section, + SectionKind, + ServerCutPosition, +} from "../interfaces"; import { fetchGet } from "./fetch-utils"; function createPdfSection( @@ -10,27 +16,28 @@ function createPdfSection( hidden: boolean, ): PdfSection { return { - key: key, + key, cutOid, kind: SectionKind.Pdf, start: { - page: page, + page, position: start, }, end: { - page: page, + page, position: end, }, hidden, }; } +interface ServerCutResponse { + [pageNumber: string]: ServerCutPosition[]; +} -export async function loadSections( - filename: string, +export function loadSections( pageCount: number, -): Promise<Section[]> { - const response = await fetchGet(`/api/exam/cuts/${filename}/`); - const cuts = response.value; + cuts: ServerCutResponse, +): Section[] { let akey = -1; const sections: Section[] = []; for (let i = 1; i <= pageCount; i++) { @@ -39,7 +46,7 @@ export async function loadSections( for (const cut of cuts[i]) { const { relHeight: position, oid, cutVersion, hidden } = cut; if (position !== lastpos) { - const key = akey + "-" + lastpos + "-" + position; + const key = `${akey}-${lastpos}-${position}`; sections.push( createPdfSection(key, oid, i, lastpos, position, hidden), ); @@ -47,20 +54,20 @@ export async function loadSections( lastpos = position; } sections.push({ - oid: oid, + oid, kind: SectionKind.Answer, answers: [], allow_new_answer: true, allow_new_legacy_answer: false, hidden: true, cutHidden: hidden, - cutVersion: cutVersion, + cutVersion, name: cut.name, }); } } if (lastpos < 1) { - const key = akey + "-" + lastpos + "-" + 1; + const key = `${akey}-${lastpos}-${1}`; sections.push(createPdfSection(key, undefined, i, lastpos, 1, false)); akey++; } diff --git a/frontend/src/api/faq.ts b/frontend/src/api/faq.ts new file mode 100644 index 0000000000000000000000000000000000000000..8aba8c0254313ee57b89a01cf4f3f5569a250bbf --- /dev/null +++ b/frontend/src/api/faq.ts @@ -0,0 +1,64 @@ +import { fetchGet, fetchPost, fetchPut, fetchDelete } from "./fetch-utils"; +import { FAQEntry } from "../interfaces"; +import { useRequest } from "@umijs/hooks"; +import { useMutation } from "./hooks"; + +const loadFAQs = async () => { + return (await fetchGet("/api/faq/")).value as FAQEntry[]; +}; +const addFAQ = async (question: string, answer: string, order: number) => { + return ( + await fetchPost("/api/faq/", { + question, + answer, + order, + }) + ).value as FAQEntry; +}; +const updateFAQ = async (oid: string, changes: Partial<FAQEntry>) => { + return (await fetchPut(`/api/faq/${oid}/`, changes)).value as FAQEntry; +}; +const swapFAQ = async (a: FAQEntry, b: FAQEntry) => { + return Promise.all([ + updateFAQ(a.oid, { order: b.order }), + updateFAQ(b.oid, { order: a.order }), + ]); +}; +const deleteFAQ = async (oid: string) => { + await fetchDelete(`/api/faq/${oid}/`); + return oid; +}; +const sorted = (arg: FAQEntry[]) => arg.sort((a, b) => a.order - b.order); + +export const useFAQ = () => { + const { data: faqs, mutate } = useRequest(loadFAQs, { cacheKey: "faqs" }); + const [, runAddFAQ] = useMutation(addFAQ, newFAQ => { + mutate(prevEntries => sorted([...prevEntries, newFAQ])); + }); + const [, runUpdateFAQ] = useMutation(updateFAQ, changed => + mutate(prevEntry => + sorted( + prevEntry.map(entry => (entry.oid === changed.oid ? changed : entry)), + ), + ), + ); + const [, runSwapFAQ] = useMutation(swapFAQ, ([newA, newB]) => { + mutate(prevEntry => + sorted( + prevEntry.map(entry => + entry.oid === newA.oid ? newA : entry.oid === newB.oid ? newB : entry, + ), + ), + ); + }); + const [, runDeleteFAQ] = useMutation(deleteFAQ, removedOid => + mutate(prevEntry => prevEntry.filter(entry => entry.oid !== removedOid)), + ); + return { + faqs, + add: runAddFAQ, + update: runUpdateFAQ, + swap: runSwapFAQ, + remove: runDeleteFAQ, + } as const; +}; diff --git a/frontend/src/fetch-utils.tsx b/frontend/src/api/fetch-utils.tsx similarity index 74% rename from frontend/src/fetch-utils.tsx rename to frontend/src/api/fetch-utils.tsx index 00d44d2b2840ef46e7c2d653e621083ca833c3ed..c4d27db769d6b8f81be96c1805c5ee60b3e34611 100644 --- a/frontend/src/fetch-utils.tsx +++ b/frontend/src/api/fetch-utils.tsx @@ -1,6 +1,6 @@ -import { ImageHandle } from "./components/Editor/utils/types"; +import { ImageHandle } from "../components/Editor/utils/types"; -async function performDataRequest( +async function performDataRequest<T>( method: string, url: string, data: { [key: string]: any }, @@ -32,51 +32,66 @@ async function performDataRequest( if (!response.ok) { return Promise.reject(body.err); } - return body; + return body as T; } catch (e) { return Promise.reject(e.toString()); } } -async function performRequest(method: string, url: string) { +async function performRequest<T>(method: string, url: string) { const response = await fetch(url, { credentials: "include", headers: { "X-CSRFToken": getCookie("csrftoken") || "", }, - method: method, + method, }); try { const body = await response.json(); if (!response.ok) { return Promise.reject(body.err); } - return body; + return body as T; } catch (e) { return Promise.reject(e.toString()); } } -export function fetchPost(url: string, data: { [key: string]: any }) { - return performDataRequest("POST", url, data); +export function getCookie(name: string): string | null { + let cookieValue = null; + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";"); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + // Does this cookie string begin with the name we want? + if (cookie.substring(0, name.length + 1) === `${name}=`) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} +export function fetchPost<T = any>(url: string, data: { [key: string]: any }) { + return performDataRequest<T>("POST", url, data); } -export function fetchPut(url: string, data: { [key: string]: any }) { - return performDataRequest("PUT", url, data); +export function fetchPut<T = any>(url: string, data: { [key: string]: any }) { + return performDataRequest<T>("PUT", url, data); } -export function fetchDelete(url: string) { - return performRequest("DELETE", url); +export function fetchDelete<T = any>(url: string) { + return performRequest<T>("DELETE", url); } -export function fetchGet(url: string) { - return performRequest("GET", url); +export function fetchGet<T = any>(url: string) { + return performRequest<T>("GET", url); } export function imageHandler(file: File): Promise<ImageHandle> { return new Promise((resolve, reject) => { fetchPost("/api/image/upload/", { - file: file, + file, }) .then(res => { resolve({ @@ -90,18 +105,3 @@ export function imageHandler(file: File): Promise<ImageHandle> { .catch(e => reject(e)); }); } -export function getCookie(name: string): string | null { - let cookieValue = null; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === name + "=") { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8e612cb8d22b463d28c1c667269e67f158f3255 --- /dev/null +++ b/frontend/src/api/hooks.ts @@ -0,0 +1,345 @@ +import { useRequest } from "@umijs/hooks"; +import { + Answer, + AnswerSection, + CategoryExam, + CategoryMetaData, + CategoryMetaDataMinimal, + CutVersions, + ExamMetaData, + FeedbackEntry, + MetaCategory, + NotificationInfo, + PaymentInfo, + ServerCutResponse, + UserInfo, +} from "../interfaces"; +import PDF from "../pdf/pdf-renderer"; +import { getDocument, PDFDocumentProxy } from "../pdf/pdfjs"; +import { fetchGet, fetchPost } from "./fetch-utils"; + +const loadUserInfo = async (username: string) => { + return (await fetchGet(`/api/scoreboard/userinfo/${username}/`)) + .value as UserInfo; +}; + +export const useUserInfo = (username: string) => { + const { error, loading, data } = useRequest(() => loadUserInfo(username), { + refreshDeps: [username], + cacheKey: `userinfo-${username}`, + }); + return [error, loading, data] as const; +}; +const loadEnabledNotifications = async (isMyself: boolean) => { + if (isMyself) { + return new Set<number>( + (await fetchGet(`/api/notification/getenabled/`)).value, + ); + } else { + return undefined; + } +}; +export const useEnabledNotifications = (isMyself: boolean) => { + const { error, loading, data, run } = useRequest( + () => loadEnabledNotifications(isMyself), + { + refreshDeps: [isMyself], + cacheKey: "enabled-notifications", + }, + ); + return [error, loading, data, run] as const; +}; +const setEnabledNotifications = async (type: number, enabled: boolean) => { + await fetchPost(`/api/notification/setenabled/`, { + type, + enabled, + }); +}; +export const useSetEnabledNotifications = (cb: () => void) => { + const { error, loading, run } = useRequest(setEnabledNotifications, { + manual: true, + onSuccess: cb, + }); + return [error, loading, run] as const; +}; +const loadPayments = async (username: string, isMyself: boolean) => { + const query = isMyself + ? "/api/payment/me/" + : `/api/payment/query/${username}/`; + return (await fetchGet(query)).value as PaymentInfo[]; +}; +export const usePayments = (username: string, isMyself: boolean) => { + const { error, loading, data, run } = useRequest( + () => loadPayments(username, isMyself), + { + refreshDeps: [username, isMyself], + cacheKey: `payments-${username}`, + }, + ); + return [error, loading, data, run] as const; +}; +const addPayment = async (username: string) => { + return (await fetchPost("/api/payment/pay/", { username })).value; +}; +export const useAddPayments = (cb: () => void) => { + const { error, loading, run } = useRequest(addPayment, { + manual: true, + onSuccess: cb, + }); + return [error, loading, run] as const; +}; +const removePayment = async (payment: string) => { + return await fetchPost(`/api/payment/remove/${payment}/`, {}); +}; +export const useRemovePayment = (cb: () => void) => { + const { error, loading, run } = useRequest(removePayment, { + manual: true, + onSuccess: cb, + }); + return [error, loading, run] as const; +}; +const refundPayment = async (payment: string) => { + return await fetchPost(`/api/payment/refund/${payment}/`, {}); +}; +export const useRefundPayment = (cb: () => void) => { + const { error, loading, run } = useRequest(refundPayment, { + manual: true, + onSuccess: cb, + }); + return [error, loading, run] as const; +}; +const loadNotifications = async (mode: "all" | "unread") => { + if (mode === "all") { + return (await fetchGet("/api/notification/all/")) + .value as NotificationInfo[]; + } else { + return (await fetchGet("/api/notification/unread/")) + .value as NotificationInfo[]; + } +}; +export const useNotifications = (mode: "all" | "unread") => { + const { error, loading, data, run } = useRequest( + () => loadNotifications(mode), + { + refreshDeps: [mode], + cacheKey: `notifications-${mode}`, + }, + ); + return [error, loading, data, run] as const; +}; +const markAllRead = async (...ids: string[]) => { + return Promise.all( + ids.map(oid => + fetchPost(`/api/notification/setread/${oid}/`, { + read: true, + }), + ), + ); +}; +export const useMarkAllAsRead = () => { + const { error, loading, run } = useRequest(markAllRead, { + manual: true, + }); + return [error, loading, run] as const; +}; +const loadUserAnswers = async (username: string) => { + return (await fetchGet(`/api/exam/listbyuser/${username}/`)) + .value as Answer[]; +}; +export const useUserAnswers = (username: string) => { + const { error, loading, data, run } = useRequest( + () => loadUserAnswers(username), + { + refreshDeps: [username], + cacheKey: `user-answers-${username}`, + }, + ); + return [error, loading, data, run] as const; +}; +const logout = async () => { + await fetchPost("/api/auth/logout/", {}); +}; +export const useLogout = (cb: () => void = () => {}) => { + const { error, loading, run } = useRequest(logout, { + manual: true, + onSuccess: cb, + }); + return [error, loading, run] as const; +}; +export const loadCategories = async () => { + return (await fetchGet("/api/category/listonlyadmin/")) + .value as CategoryMetaDataMinimal[]; +}; +export const uploadPdf = async ( + file: Blob, + displayname: string, + category: string, +) => { + return ( + await fetchPost("/api/exam/upload/exam/", { file, displayname, category }) + ).filename as string; +}; +export const uploadTranscript = async (file: Blob, category: string) => { + return (await fetchPost("/api/exam/upload/transcript/", { file, category })) + .filename as string; +}; +export const loadCategoryMetaData = async (slug: string) => { + return (await fetchGet(`/api/category/metadata/${slug}`)) + .value as CategoryMetaData; +}; +export const loadMetaCategories = async () => { + return (await fetchGet("/api/category/listmetacategories")) + .value as MetaCategory[]; +}; +export const loadList = async (slug: string) => { + return (await fetchGet(`/api/category/listexams/${slug}`)) + .value as CategoryExam[]; +}; +export const claimExam = async (filename: string, claim: boolean) => { + await fetchPost(`/api/exam/claimexam/${filename}/`, { + claim, + }); +}; +export const loadExamMetaData = async (filename: string) => { + return (await fetchGet(`/api/exam/metadata/${filename}/`)) + .value as ExamMetaData; +}; +export const loadSplitRenderer = async (filename: string) => { + const pdf = await new Promise<PDFDocumentProxy>((resolve, reject) => + getDocument(`/api/exam/pdf/exam/${filename}`).promise.then(resolve, reject), + ); + const renderer = new PDF(pdf); + return [pdf, renderer] as const; +}; +export const loadCutVersions = async (filename: string) => { + return (await fetchGet(`/api/exam/cutversions/${filename}/`)) + .value as CutVersions; +}; +export const loadCuts = async (filename: string) => { + return (await fetchGet(`/api/exam/cuts/${filename}/`)) + .value as ServerCutResponse; +}; +export const submitFeedback = async (text: string) => { + return await fetchPost("api/feedback/submit/", { text }); +}; +export const loadFeedback = async () => { + const fb = (await fetchGet("/api/feedback/list/")).value as FeedbackEntry[]; + const getScore = (a: FeedbackEntry) => (a.read ? 10 : 0) + (a.done ? 1 : 0); + fb.sort((a: FeedbackEntry, b: FeedbackEntry) => getScore(a) - getScore(b)); + return fb; +}; +export const loadPaymentCategories = async () => { + return (await fetchGet("/api/category/listonlypayment/")) + .value as CategoryMetaData[]; +}; +const loadAnswers = async (oid: string) => { + const section = (await fetchGet(`/api/exam/answersection/${oid}/`)) + .value as AnswerSection; + const getScore = (answer: Answer) => answer.expertvotes * 10 + answer.upvotes; + section.answers.sort((a, b) => getScore(b) - getScore(a)); + return section; +}; +export const useAnswers = ( + oid: string, + onSuccess: (data: AnswerSection) => void, +) => { + const { run } = useRequest(() => loadAnswers(oid), { + manual: true, + onSuccess, + }); + return run; +}; +const removeSplit = async (oid: string) => { + return await fetchPost(`/api/exam/removecut/${oid}/`, {}); +}; +export const useRemoveSplit = (oid: string, onSuccess: () => void) => { + const { run: runRemoveSplit } = useRequest(() => removeSplit(oid), { + manual: true, + onSuccess, + }); + return runRemoveSplit; +}; + +const updateAnswer = async ( + answerId: string, + text: string, + legacy_answer: boolean, +) => { + return ( + await fetchPost(`/api/exam/setanswer/${answerId}/`, { text, legacy_answer }) + ).value as AnswerSection; +}; +const removeAnswer = async (answerId: string) => { + return (await fetchPost(`/api/exam/removeanswer/${answerId}/`, {})) + .value as AnswerSection; +}; +const setFlagged = async (oid: string, flagged: boolean) => { + return ( + await fetchPost(`/api/exam/setflagged/${oid}/`, { + flagged, + }) + ).value as AnswerSection; +}; +const resetFlagged = async (oid: string) => { + return (await fetchPost(`/api/exam/resetflagged/${oid}/`, {})) + .value as AnswerSection; +}; +const setExpertVote = async (oid: string, vote: boolean) => { + return ( + await fetchPost(`/api/exam/setexpertvote/${oid}/`, { + vote, + }) + ).value as AnswerSection; +}; + +export const useSetFlagged = ( + onSectionChanged?: (data: AnswerSection) => void, +) => { + const { + loading: setFlaggedLoading, + run: runSetFlagged, + } = useRequest(setFlagged, { manual: true, onSuccess: onSectionChanged }); + return [setFlaggedLoading, runSetFlagged] as const; +}; +export const useSetExpertVote = ( + onSectionChanged?: (data: AnswerSection) => void, +) => { + const { + loading: setExpertVoteLoading, + run: runSetExpertVote, + } = useRequest(setExpertVote, { manual: true, onSuccess: onSectionChanged }); + return [setExpertVoteLoading, runSetExpertVote] as const; +}; +export const useResetFlaggedVote = ( + onSectionChanged?: (data: AnswerSection) => void, +) => { + const { + loading: resetFlaggedLoading, + run: runResetFlagged, + } = useRequest(resetFlagged, { manual: true, onSuccess: onSectionChanged }); + return [resetFlaggedLoading, runResetFlagged] as const; +}; +export const useUpdateAnswer = (onSuccess?: (data: AnswerSection) => void) => { + const { loading: updating, run: runUpdateAnswer } = useRequest(updateAnswer, { + manual: true, + onSuccess, + }); + return [updating, runUpdateAnswer] as const; +}; +export const useRemoveAnswer = ( + onSectionChanged?: (data: AnswerSection) => void, +) => { + const { run: runRemoveAnswer } = useRequest(removeAnswer, { + manual: true, + onSuccess: onSectionChanged, + }); + return runRemoveAnswer; +}; + +export const useMutation = <B, T extends any[]>( + service: (...args: T) => Promise<B>, + onSuccess?: (res: B) => void, +) => { + const { loading, run } = useRequest(service, { manual: true, onSuccess }); + return [loading, run] as const; +}; diff --git a/frontend/src/api/image.ts b/frontend/src/api/image.ts new file mode 100644 index 0000000000000000000000000000000000000000..a424e95db9e1ba30b172295cd067dbda2b363941 --- /dev/null +++ b/frontend/src/api/image.ts @@ -0,0 +1,41 @@ +import { fetchGet, fetchPost } from "./fetch-utils"; +import { useRequest } from "@umijs/hooks"; +import { remove } from "lodash"; + +export const loadImage = async () => { + return (await fetchGet("/api/image/list/")).value as string[]; +}; +export const removeImage = async (image: string) => { + await fetchPost(`/api/image/remove/${image}/`, {}); + return image; +}; +export const uploadImage = async (file: File) => { + return (await fetchPost("/api/image/upload/", { file })).filename as string; +}; + +export const useImages = () => { + const { data: images, mutate, run: reload } = useRequest(loadImage, { + cacheKey: "images", + }); + + const { run: runRemoveImage } = useRequest(removeImage, { + manual: true, + fetchKey: id => id, + onSuccess: removed => { + mutate(prev => prev.filter(image => image !== removed)); + remove(removed); + }, + }); + const { run: runUploadImage } = useRequest(uploadImage, { + manual: true, + onSuccess: added => { + mutate(prevSelected => [...prevSelected, added]); + }, + }); + return { + images, + add: runUploadImage, + remove: runRemoveImage, + reload, + } as const; +}; diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 8b60f9f5fff1da026775d48a10e03faba6e9d4f2..6337da58a27908c4f690e8e22e853be80626256b 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -1,244 +1,129 @@ -import * as React from "react"; -import Exam from "./pages/exam"; -import UploadPDF from "./pages/uploadpdf"; -import Category from "./pages/category"; -import Home from "./pages/home"; -import { Route, Switch } from "react-router"; -import Header from "./components/header"; -import { css } from "glamor"; -import Feedback from "./pages/feedback"; -import Colors from "./colors"; -import { fetchGet, getCookie } from "./fetch-utils"; -import Scoreboard from "./pages/scoreboard"; -import UserInfoComponent from "./pages/userinfo"; -import ModQueue from "./pages/modqueue"; -import SubmitTranscript from "./pages/submittranscript"; -import colors from "./colors"; -import globalcss from "./globalcss"; -import LoginForm from "./components/loginform"; +import { Button } from "@vseth/components"; +import React, { useEffect, useState } from "react"; +import { Route, Switch } from "react-router-dom"; +import { fetchGet, getCookie } from "./api/fetch-utils"; +import { notLoggedIn, SetUserContext, User, UserContext } from "./auth"; +import UserRoute from "./auth/UserRoute"; +import { DebugContext, defaultDebugOptions } from "./components/Debug"; +import DebugModal from "./components/Debug/DebugModal"; +import ExamsNavbar from "./components/exams-navbar"; import HashLocationHandler from "./components/hash-location-handler"; -import TutorialPage from "./pages/tutorial"; -import FAQ from "./pages/faq"; +import useToggle from "./hooks/useToggle"; +import CategoryPage from "./pages/category-page"; +import ExamPage from "./pages/exam-page"; +import FAQ from "./pages/faq-page"; +import FeedbackPage from "./pages/feedback-page"; +import HomePage from "./pages/home-page"; +import LoginPage from "./pages/login-page"; +import ModQueue from "./pages/modqueue-page"; +import NotFoundPage from "./pages/not-found-page"; +import Scoreboard from "./pages/scoreboard-page"; +import UploadTranscriptPage from "./pages/submittranscript-page"; +import TutorialPage from "./pages/tutorial-page"; +import UploadPdfPage from "./pages/uploadpdf-page"; +import UserPage from "./pages/userinfo-page"; -css.global("body", { - fontFamily: - '"Avenir Next","Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"', - background: colors.pageBackground, -}); -css.global("h1", { - fontFamily: - 'Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"', - marginBlockStart: "0.4em", - marginBlockEnd: "0.4em", -}); -css.global("h2", { - fontFamily: - 'Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"', - marginBlockStart: "0.3em", - marginBlockEnd: "0.3em", -}); -css.global("a", { - textDecoration: "none", -}); -css.global("a:link", { - color: Colors.link, -}); -css.global("a:visited", { - color: Colors.linkVisited, -}); -css.global("a:hover", { - color: Colors.linkHover, -}); -css.global("button", globalcss.button); -css.global("input", { - margin: "5px", - padding: "7px", - border: "1px solid " + Colors.inputBorder, - borderRadius: "2px", - boxSizing: "border-box", -}); -css.global("button[disabled]", { - background: Colors.buttonBackgroundDisabled, -}); -css.global("button:hover", { - background: Colors.buttonBackgroundHover, -}); -css.global("button[disabled]:hover", { - cursor: "not-allowed", -}); -css.global(".primary", { - background: Colors.buttonPrimary, -}); -css.global(".primary:hover", { - background: Colors.buttonPrimaryHover, -}); -css.global("table", { - borderCollapse: "collapse", - boxShadow: Colors.tableShadow, - textAlign: "left", -}); -css.global("table td, table th", { - border: "none", - padding: "8px", -}); -css.global("table th", { - background: Colors.tableHeader, -}); -css.global("thead tr", { - borderBottom: "1px solid " + Colors.tableHeaderBorder, -}); -css.global("table tr:nth-child(even)", { - background: Colors.tableEven, -}); -css.global("table tr:nth-child(odd)", { - background: Colors.tableOdd, -}); - -const styles = { - inner: css({ - padding: "15px", - }), -}; - -interface State { - loadedSessionData: boolean; - loggedin: boolean; - username: string; - displayname: string; - isAdmin: boolean; - isCategoryAdmin: boolean; -} - -export default class App extends React.Component<{}, State> { - state: State = { - loadedSessionData: false, - loggedin: false, - username: "", - displayname: "", - isAdmin: false, - isCategoryAdmin: false, - }; - - componentDidMount() { - this.loadCsrfCookie(); - this.loadMe(); - } - - loadMe = () => { - fetchGet("/api/auth/me/") - .then(res => - this.setState({ - loadedSessionData: true, - loggedin: res.loggedin, - username: res.username, - displayname: res.displayname, - isAdmin: res.adminrights, - isCategoryAdmin: res.adminrightscat, - }), - ) - .catch(() => - this.setState({ - loadedSessionData: true, - }), +const App: React.FC<{}> = () => { + useEffect(() => { + // We need to manually get the csrf cookie when the frontend is served using + // `yarn start` as only certain pages will set the csrf cookie. + // Technically the application won't work until the promise resolves, but we just + // hope that the user doesn't do anything in that time. + if (getCookie("csrftoken") === null) { + fetchGet("/api/can_i_haz_csrf_cookie/"); + } + }, []); + const [user, setUser] = useState<User | undefined>(undefined); + useEffect(() => { + let cancelled = false; + if (user === undefined) { + fetchGet("/api/auth/me/").then( + res => { + if (cancelled) return; + setUser({ + loggedin: res.loggedin, + username: res.username, + displayname: res.displayname, + isAdmin: res.adminrights, + isCategoryAdmin: res.adminrightscat, + }); + }, + () => { + setUser(notLoggedIn); + }, ); - }; - - loadCsrfCookie = () => { - if (getCookie("csrftoken") == null) { - fetchGet("/api/can_i_haz_csrf_cookie/").then(r => {}); } - }; - - render() { - return ( - <div> - <Route component={HashLocationHandler} /> - <Header - username={this.state.username} - displayName={this.state.displayname || "loading..."} - /> - <div {...styles.inner}> - {!this.state.loadedSessionData && "loading..."} - {this.state.loadedSessionData && !this.state.loggedin && ( - <LoginForm userinfoChanged={this.loadMe} /> - )} - {this.state.loggedin && ( - <Switch> - <Route - path="/exams/:filename" - render={props => ( - <Exam - {...props} - isAdmin={this.state.isAdmin} - filename={props.match.params.filename} + return () => { + cancelled = true; + }; + }, [user]); + const [debugPanel, toggleDebugPanel] = useToggle(false); + const [debugOptions, setDebugOptions] = useState(defaultDebugOptions); + return ( + <> + <Route component={HashLocationHandler} /> + <DebugContext.Provider value={debugOptions}> + <UserContext.Provider value={user}> + <SetUserContext.Provider value={setUser}> + <div className="mobile-capable"> + <ExamsNavbar /> + <main className="main__container"> + <Switch> + <UserRoute exact path="/" component={HomePage} /> + <Route exact path="/login" component={LoginPage} /> + <Route exact path="/tutorial" component={TutorialPage} /> + <UserRoute + exact + path="/uploadpdf" + component={UploadPdfPage} /> - )} - /> - <Route - path="/user/:username" - render={props => ( - <UserInfoComponent - {...props} - isMyself={ - this.state.username === props.match.params.username - } - isAdmin={this.state.isAdmin} - username={props.match.params.username} - userinfoChanged={this.loadMe} + <UserRoute + exact + path="/submittranscript" + component={UploadTranscriptPage} /> - )} - /> - <Route - path="/category/:category" - render={props => ( - <Category - categorySlug={props.match.params.category} - username={this.state.username} - isAdmin={this.state.isAdmin} + <UserRoute exact path="/faq" component={FAQ} /> + <UserRoute exact path="/feedback" component={FeedbackPage} /> + <UserRoute + exact + path="/category/:slug" + component={CategoryPage} /> - )} - /> - <Route path="/uploadpdf" component={UploadPDF} /> - <Route path="/submittranscript" component={SubmitTranscript} /> - <Route - path="/scoreboard" - render={props => <Scoreboard username={this.state.username} />} - /> - <Route - path="/modqueue" - render={props => ( - <ModQueue - isAdmin={this.state.isAdmin} - username={this.state.username} + <UserRoute + exact + path="/exams/:filename" + component={ExamPage} /> - )} - /> - <Route - path="/feedback" - render={props => ( - <Feedback {...props} isAdmin={this.state.isAdmin} /> - )} - /> - <Route - path="/faq" - render={props => ( - <FAQ {...props} isAdmin={this.state.isAdmin} /> - )} - /> - <Route path="/tutorial" render={_props => <TutorialPage />} /> - <Route - render={props => ( - <Home - {...props} - isAdmin={this.state.isAdmin} - isCategoryAdmin={this.state.isCategoryAdmin} + <UserRoute + exact + path="/user/:username" + component={UserPage} /> - )} - /> - </Switch> - )} - </div> - </div> - ); - } -} + <UserRoute exact path="/scoreboard" component={Scoreboard} /> + <UserRoute exact path="/modqueue" component={ModQueue} /> + <Route component={NotFoundPage} /> + </Switch> + </main> + </div> + </SetUserContext.Provider> + </UserContext.Provider> + </DebugContext.Provider> + {process.env.NODE_ENV === "development" && ( + <> + <div className="position-fixed" style={{ bottom: 0, left: 0 }}> + <Button color="white" onClick={toggleDebugPanel}> + DEBUG + </Button> + </div> + <DebugModal + isOpen={debugPanel} + toggle={toggleDebugPanel} + debugOptions={debugOptions} + setDebugOptions={setDebugOptions} + /> + </> + )} + </> + ); +}; +export default App; diff --git a/frontend/src/assets/bjoern.svg b/frontend/src/assets/bjoern.svg new file mode 100644 index 0000000000000000000000000000000000000000..28b27d75334df2a6c34f00edabe38ce286d0a5c4 --- /dev/null +++ b/frontend/src/assets/bjoern.svg @@ -0,0 +1,126 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg width="100%" height="100%" viewBox="0 0 1952 2014" version="1.1" xmlns="http://www.w3.org/2000/svg" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;"> + <g id="björn" transform="matrix(1,0,0,1,0,73)"> + <g id="bear"> + <g transform="matrix(0.884334,0,0,0.892567,378.524,1547.25)"> + <path d="M31.218,52.027C59.308,13.645 149.838,0 149.838,0C149.838,0 163.593,74.045 181.054,104.054C199.896,136.436 272.622,187.297 272.622,187.297C272.622,187.297 111.522,250.66 52.029,208.108C26.214,189.645 0,165.611 0,135.27C0,104.054 13.746,75.9 31.218,52.027Z" style="fill:none;stroke:black;stroke-width:67.53px;"/> + <path d="M31.218,52.027C59.308,13.645 149.838,0 149.838,0C149.838,0 163.593,74.045 181.054,104.054C199.896,136.436 272.622,187.297 272.622,187.297C272.622,187.297 111.522,250.66 52.029,208.108C26.214,189.645 0,165.611 0,135.27C0,104.054 13.746,75.9 31.218,52.027Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,488.981,10.3575)"> + <path d="M284.719,200.743C284.719,200.743 255.103,165.523 253.502,138.311C251.685,107.417 261.558,85.999 284.719,65.473C309.797,43.248 336.289,33.722 367.962,44.662C397.96,55.024 419.989,107.095 419.989,107.095C419.989,107.095 473.016,61.449 513.638,44.662C563.034,24.249 595.614,27.903 648.908,23.851C701.583,19.847 731.544,19.35 784.179,23.851C833.434,28.064 909.044,44.662 909.044,44.662C909.044,44.662 918.101,22.193 929.854,13.446C943.295,3.443 954.819,1.234 971.476,3.04C997.344,5.845 1010.56,22.092 1023.5,44.662C1035.8,66.103 1023.5,107.095 1023.5,107.095C1023.5,107.095 1091.03,176.887 1117.15,231.96C1157.55,317.119 1121.08,381.068 1148.37,471.284C1173.11,553.056 1234.4,583.895 1242.02,668.987C1249.92,757.384 1189.61,799.319 1179.58,887.501C1172.7,948.065 1173.62,982.921 1179.58,1043.58C1186.92,1118.15 1197.49,1159.8 1221.21,1230.88C1237.92,1280.99 1254.53,1306.34 1273.23,1355.75C1295.98,1415.84 1321.12,1447.71 1325.26,1511.83C1330.4,1591.5 1319.14,1644.21 1273.23,1709.53C1212.62,1795.78 1136.48,1810.08 1033.91,1834.39C923.126,1860.65 856.409,1823.68 742.557,1823.99C636.826,1824.28 577.355,1843.49 472.016,1834.39C360.04,1824.72 289.274,1826.63 191.07,1771.96C120.593,1732.73 72.433,1708.13 34.989,1636.69C6.132,1581.63 9.957,1542.46 3.772,1480.61C-7.173,1371.14 7.516,1308.35 24.583,1199.66C44.765,1071.14 83.705,1004.94 107.827,877.095C134.369,736.427 111.695,650.988 149.448,512.906C184.488,384.749 284.719,200.743 284.719,200.743Z" style="fill:white;fill-rule:nonzero;stroke:black;stroke-width:30px;"/> + </g> + <g transform="matrix(0.884334,0,0,0.892567,378.524,1547.25)"> + <path d="M31.218,52.027C59.308,13.645 129.318,0 129.318,0C128.685,67.349 158.001,136.584 232.878,205.512C232.878,205.512 111.522,250.66 52.029,208.108C26.214,189.645 0,165.611 0,135.27C0,104.054 13.746,75.9 31.218,52.027Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g id="facce"> + <g transform="matrix(0,-1,-1,0,1234.99,426.788)"> + <ellipse cx="31.216" cy="41.621" rx="41.622" ry="31.216"/> + </g> + <g transform="matrix(0,-1,-1,0,1594.59,458.41)"> + <ellipse cx="31.216" cy="41.621" rx="41.622" ry="31.216"/> + </g> + <g transform="matrix(0.998471,0.055271,0.055271,-0.998471,1386.34,508.666)"> + <ellipse cx="42.114" cy="0.806" rx="93.646" ry="42.114"/> + </g> + <g transform="matrix(1,0,0,1,1292.83,638.817)"> + <path d="M23.413,18.95C54.628,29.357 105.874,61.858 148.28,60.573C186.813,59.405 210.712,50.168 241.929,29.357C273.145,8.546 283.551,46.211 241.929,67.022C200.306,87.834 186.812,92.878 148.279,94.046C105.874,95.331 54.631,65.47 23.413,55.063C-7.806,44.657 -7.803,8.543 23.413,18.95Z" style="fill-rule:nonzero;"/> + </g> + <g transform="matrix(-0.205509,-0.978655,-0.978655,0.205509,1402.64,514.513)"> + <ellipse cx="20.811" cy="18.515" rx="10.405" ry="20.811" style="fill:white;fill-opacity:0.2;"/> + </g> + </g> + <g transform="matrix(0.43956,-0.073319,0.164528,0.986372,399.525,1.2028)"> + <path d="M957.745,347.444C957.745,336.406 937.635,327.444 912.865,327.444C888.095,327.444 867.985,336.406 867.985,347.444L870.985,1043.94C870.985,1054.98 891.095,1063.94 915.865,1063.94C940.635,1063.94 960.745,1054.98 960.745,1043.94L957.745,347.444Z"/> + </g> + <g transform="matrix(1,0,0,1,762.156,1336.12)"> + <path d="M197.703,6.75C242.977,2.969 269.188,12.82 312.163,27.56C360.324,44.08 390.926,53.685 426.622,89.993C480.675,144.972 489.055,200.19 489.055,277.29C489.055,370.938 499.46,482.798 468.244,506.21C426.622,537.425 332.973,537.425 239.325,537.425C165.171,537.425 104.054,549.386 52.027,485.399C10.93,434.854 41.622,318.912 41.622,318.912L0,235.669C0,235.669 5.967,137.209 41.622,89.993C83.251,34.865 128.862,12.498 197.703,6.75Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g transform="matrix(-1,0,0,1,1744.15,1335.71)"> + <path d="M197.703,6.749C242.977,2.969 269.188,12.82 312.162,27.56C360.324,44.08 390.926,53.685 426.622,89.993C480.675,144.972 489.055,200.189 489.055,277.29C489.055,370.938 503.347,493.617 472.13,517.028C430.509,548.243 336.86,548.244 243.211,548.244C169.058,548.244 149.563,549.799 97.536,485.812C56.438,435.267 87.13,319.325 87.13,319.325L0,235.669C0,235.669 5.967,137.209 41.622,89.993C83.251,34.865 128.862,12.498 197.703,6.749Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g transform="matrix(1.17646,0,0,1,-128.627,-32.7413)"> + <path d="M790.239,1703.3C790.239,1697.78 786.43,1693.3 781.738,1693.3C777.047,1693.3 773.238,1697.78 773.238,1703.3C767.521,1755.93 767.95,1808.54 777.826,1861.13C777.826,1866.65 781.635,1871.13 786.326,1871.13C791.017,1871.13 800.677,1863.01 800.677,1857.49C798.796,1846.94 791.401,1840.02 790.183,1829.44C785.362,1787.57 785.643,1745.52 790.239,1703.3Z"/> + </g> + <g transform="matrix(1.13638,0.304491,0.258819,-0.965926,-567.038,2959.37)"> + <path d="M790.058,1704.43C790.058,1698.91 786.249,1694.43 781.558,1694.43C776.867,1694.43 773.058,1698.91 773.058,1704.43C766.515,1760.05 774.674,1821.72 783.062,1842.85C786.752,1852.15 810.488,1858.41 804.602,1849.54C793.505,1832.82 782.596,1762.65 790.058,1704.43Z"/> + </g> + <g transform="matrix(0.373066,-0.0999628,0.258819,0.965926,189.365,174.662)"> + <path d="M1059.3,1768.81C1059.3,1763.29 1047.7,1758.81 1033.41,1758.81C1019.12,1758.81 1007.51,1763.29 1007.51,1768.81C1009.42,1800.78 997.472,1831.27 972.55,1860.38C972.55,1865.9 983.115,1875.63 997.405,1875.63C1011.7,1875.63 1023.3,1871.15 1023.3,1865.63C1048.46,1834.87 1061.35,1802.7 1059.3,1768.81Z"/> + </g> + <g transform="matrix(0.325821,-0.207393,0.536971,0.843601,-147.917,510.213)"> + <path d="M1059.3,1768.81C1059.3,1763.29 1047.7,1758.81 1033.41,1758.81C1019.12,1758.81 1007.51,1763.29 1007.51,1768.81C1007.35,1798.66 982.211,1821.56 936.707,1840.64C936.707,1846.16 946.325,1860.19 960.614,1860.19C974.904,1860.19 984.092,1855.11 984.092,1849.59C1042.58,1824.05 1059.3,1796.05 1059.3,1768.81Z"/> + </g> + <g transform="matrix(-1,0,0,1,2478.75,0)"> + <g transform="matrix(0.373066,-0.0999628,0.258819,0.965926,189.365,174.662)"> + <path d="M1059.3,1768.81C1059.3,1763.29 1047.7,1758.81 1033.41,1758.81C1019.12,1758.81 1007.51,1763.29 1007.51,1768.81C1009.42,1800.78 991.091,1840.47 966.168,1869.58C966.168,1875.1 976.733,1884.83 991.023,1884.83C1005.31,1884.83 1016.92,1880.35 1016.92,1874.83C1042.08,1844.07 1061.35,1802.7 1059.3,1768.81Z"/> + </g> + <g transform="matrix(0.325821,-0.207393,0.536971,0.843601,-147.917,510.213)"> + <path d="M1059.3,1768.81C1060.87,1763.52 1047.7,1758.81 1033.41,1758.81C1019.12,1758.81 1007.51,1763.29 1007.51,1768.81C998.294,1796.48 963.621,1825.06 930.089,1847.04C930.089,1852.56 940.743,1865.87 955.033,1865.87C969.323,1865.87 978.511,1860.8 978.511,1855.28C1021.29,1825.77 1051.78,1794.15 1059.3,1768.81Z"/> + </g> + </g> + <g transform="matrix(0.940923,-0.121234,0.127789,0.991801,-18.7031,175.525)"> + <path d="M1573.13,1689.24C1573.13,1683.72 1568.41,1679.24 1562.59,1679.24C1556.77,1679.24 1552.05,1683.72 1552.05,1689.24C1552.82,1727.87 1561.57,1766.38 1540.16,1815.03C1537.58,1820.9 1544.89,1825.03 1550.7,1825.03C1556.52,1825.03 1561.24,1820.55 1561.24,1815.03C1576.77,1780.64 1574.33,1728.14 1573.13,1689.24Z"/> + </g> + <g transform="matrix(-0.940923,-0.121234,-0.127789,0.991801,2949.31,216.638)"> + <path d="M1455.3,1322.83C1455.3,1317.31 1448.93,1322.33 1443.12,1322.33C1437.3,1322.33 1436.12,1330.14 1436.12,1335.66C1477.7,1359.39 1559.9,1423.13 1577.79,1512.25C1583.72,1541.8 1560.9,1690 1543.48,1796.7C1541.42,1809.3 1537.11,1819.8 1532.4,1827.91C1529.31,1833.23 1523.83,1841.64 1519.6,1842.97C1507.88,1846.66 1527.86,1850.73 1533.68,1850.73C1539.49,1850.73 1544.22,1846.25 1544.22,1840.73C1577.23,1795.14 1607.5,1508.27 1598.18,1506.47C1596.78,1462.47 1536.22,1370.25 1455.3,1322.83Z"/> + </g> + <g transform="matrix(0.940923,-0.121234,0.127789,0.991801,-442.13,216.638)"> + <path d="M1371.1,1292.4C1371.1,1286.89 1352.35,1292.29 1346.53,1292.29C1340.71,1292.29 1339.53,1300.1 1339.53,1305.62C1419.4,1315.74 1543.05,1382.32 1577.79,1512.25C1585.59,1541.43 1555.84,1729.37 1544.18,1787.26C1540.01,1807.99 1531.21,1825.69 1522.55,1829.39C1511.87,1833.97 1527.86,1850.73 1533.68,1850.73C1539.49,1850.73 1544.22,1846.25 1544.22,1840.73C1577.23,1795.14 1607.5,1508.27 1598.18,1506.47C1577.33,1415.39 1475.13,1309.88 1371.1,1292.4Z"/> + </g> + <g transform="matrix(1,0,0,1,564.453,886.426)"> + <path d="M62.432,92.255L208.108,92.255C208.703,95.267 376.256,38.919 457.838,81.85C522.667,115.965 557.505,164.806 561.892,237.931C566.246,310.492 528.936,351.927 478.649,404.418C435.739,449.208 401.831,466.907 343.379,487.661C285.43,508.237 247.541,520.806 187.297,508.472C121.04,494.907 89.128,462.96 41.622,414.823C0.455,373.111 0,300.364 0,300.364L62.432,92.255Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,1362.38,905.843)"> + <path d="M13.702,208.108C28.482,170.256 41.837,146.659 76.134,124.865C111.11,102.639 138.846,106.904 180.188,104.054C229.004,100.689 263.428,104.054 263.428,104.054L242.617,0C242.617,0 242.023,8.874 264.123,14.833C294.249,22.956 321.118,38.143 338.148,47.836C372.215,67.228 387.647,89.533 408.837,124.206C427.789,155.219 422.977,167.471 431.353,202.838C439.782,238.426 446.469,248.089 436.624,283.311C426.304,320.232 437.06,353.817 410.257,381.226C380.387,411.772 372.504,424.842 333.634,443.686C303.482,458.304 286.292,474.617 253.026,478.649C220.754,482.561 202.055,482.561 169.783,478.649C136.517,474.617 114.762,475.903 86.539,457.838C59.809,440.728 50.676,422.719 34.512,395.406C16.83,365.527 8.431,346.5 3.296,312.163C-2.744,271.773 -1.153,246.149 13.702,208.108Z" style="fill:white;fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,0,-73)"> + <path d="M1031,1064.79C1017.65,1054.03 1003.83,1046.74 990.773,1045.39C883.272,1034.29 846.442,1045.36 782.284,1069.36C777.114,1071.29 771.363,1068.45 769.533,1063.24C767.702,1058.04 770.437,1052.31 775.649,1050.49C847.022,1025.65 883.603,1012.79 992.774,1025.83C1006.33,1027.45 1093.48,1063.95 1122.68,1169.01C1123.68,1172.63 1152.06,1262.04 1059.98,1363.1C1039.15,1385.96 998.706,1417.56 949.477,1443.77C904.125,1467.91 870.075,1494.3 798.247,1490.76C795.808,1490.64 753.231,1489.27 704.827,1464.55C649.52,1436.29 555.938,1318.92 528.357,1283.97C488.62,1233.62 530.723,1247.85 535.928,1256.34C549.884,1279.1 628.312,1388.82 712.967,1445.89C743.141,1466.23 787.625,1469.62 809.183,1470.94C836.333,1472.62 872.472,1463.65 913.918,1441.69C919.322,1438.83 1017.22,1383.7 1045.43,1350.98C1108.32,1278.02 1110.47,1221.99 1104.41,1179.16C1103.8,1174.87 1102.62,1170.07 1100.93,1164.93C1092.13,1160.44 1063.69,1150.71 1041.2,1158.51C1032.51,1161.53 1029.1,1163.32 1026.99,1164.46C1022.12,1167.07 1016.06,1165.24 1013.44,1160.38C1010.83,1155.51 1012.66,1149.45 1017.52,1146.84C1019.88,1145.57 1024.86,1142.9 1032.71,1139.98C1054.2,1131.99 1078.34,1135.95 1089.94,1139.42C1079.86,1120.03 1065.5,1099.18 1049.42,1082.04C1033.95,1085.49 1011.8,1083.38 993.576,1101.29C985.963,1108.78 980.902,1115.38 980.902,1115.38C976.999,1119.28 970.663,1119.28 966.76,1115.38C962.857,1111.47 962.857,1105.13 966.76,1101.23C966.76,1101.23 971.303,1095.45 978.053,1088.64C990.605,1076 1004.46,1067.72 1031,1064.79Z"/> + </g> + <g transform="matrix(1,0,0,1,0,-73)"> + <path d="M1376.31,1207.73C1368.39,1239.08 1364.21,1310.21 1395.49,1371.91C1398.41,1377.67 1439.33,1459.39 1530.02,1452.57C1544.5,1451.49 1635.22,1445.27 1677.85,1434.7C1703.5,1428.34 1717.57,1414.33 1730.11,1401.9C1733.96,1398.09 1737.99,1395.38 1741.76,1391.87C1741.86,1391.77 1741.96,1391.68 1742.06,1391.59C1744.36,1389.46 1768.88,1393.37 1750.61,1412.45C1736.39,1427.31 1718.04,1445.36 1684.12,1454.26C1634.27,1467.35 1539.28,1472.23 1533.65,1472.78C1435.26,1482.31 1381.81,1389.49 1376.96,1379.7C1331.84,1288.71 1355.13,1208.09 1355.34,1207.3C1376.09,1132.21 1413.65,1111.2 1421.59,1106.51C1493.46,1064 1579,1072.03 1621.46,1076.48C1632.77,1077.66 1658.85,1081.4 1668.99,1083.05C1674.38,1083.92 1678.28,1088.86 1677.53,1094.32C1676.77,1099.79 1671.68,1103.42 1666.25,1102.86C1656.54,1101.86 1629.57,1097.75 1618.72,1096.29C1594.69,1093.05 1504.72,1082.52 1436.23,1120.34C1466.61,1128.51 1473.28,1138.71 1479.64,1145.02C1483.83,1149.17 1484.32,1154.92 1480.73,1159.12C1477.13,1163.31 1470.82,1163.79 1466.63,1160.2C1456,1151.1 1457.37,1146.36 1416.42,1134.83C1401.51,1148.84 1390.91,1166.87 1383.75,1184.82C1402.38,1179.3 1413.65,1180.88 1427.82,1183.9C1433.29,1185.07 1439.42,1187.11 1441.36,1187.68C1446.65,1189.26 1449.67,1194.83 1448.09,1200.12C1446.52,1205.41 1440.95,1208.43 1435.66,1206.85C1434.3,1206.45 1426.95,1204.42 1422.28,1203.28C1402.1,1198.39 1383.74,1205.45 1376.31,1207.73Z"/> + </g> + <g transform="matrix(0.254891,-0.367752,0.821886,0.569653,347.605,982.403)"> + <path d="M1824.38,1040.27L1757.33,1040.27C1802.31,1151.33 1764.33,1177.84 1746.8,1202.77C1730.98,1225.27 1625.65,1282.36 1557.1,1307.18C1545.41,1311.41 1464.23,1336.56 1384.84,1342.41C1344.58,1345.38 1298.6,1349.1 1194.61,1340.58C1157.8,1337.57 1126.66,1333.92 1126.66,1333.92L1130.65,1364.83C1130.65,1364.83 1138.32,1363.36 1154.84,1365.72C1241.88,1378.14 1329.87,1377.61 1406.38,1370.96C1488.51,1363.83 1552.62,1345.4 1596.59,1331.61C1675.96,1306.73 1778.6,1246.24 1811.45,1211.31C1857.08,1162.79 1857.87,1099.12 1824.38,1040.27Z"/> + </g> + <g transform="matrix(0.274493,0.104731,-0.356479,0.934303,1882.11,11.0128)"> + <path d="M1562.84,1741.82L1459.69,1742.42C1459.69,1742.42 1463.88,1825.98 1228.26,1866.96C1215.39,1869.2 908.06,1903.2 817.069,1913.05C742.148,1921.16 418.883,1946.49 334.267,1940.5C332.715,1940.39 203.744,1927.4 203.744,1927.4C203.744,1927.4 121.599,1954.45 106.939,1958.73C84.033,1965.42 85.019,1975.7 -233.538,2017.85C-257.679,2021.05 -701.496,2073.09 -741.866,2077.61C-1000.53,2106.53 -1142.02,2080.47 -1171.33,2076.78C-1191.68,2074.21 -1382.86,2071.38 -1255.86,2096.33C-1020.53,2142.57 -768.704,2113.75 -721.523,2108.84C-574.875,2093.59 -244.564,2051.76 -206.536,2047.49C0.083,2024.29 151.8,1990.34 190.296,1976.93C205.703,1971.57 225.975,1965.17 228.541,1962.33C240.162,1965.51 279.707,1967.97 295.412,1969.09C463.467,1980.99 760.225,1951.02 853.329,1940.97C1056.11,1919.09 1253.5,1896.48 1259.95,1895.53C1578.32,1848.85 1562.84,1741.82 1562.84,1741.82Z"/> + </g> + <g transform="matrix(0.16558,-0.276461,0.857898,0.51382,1015.91,365.606)"> + <path d="M1442.62,162.034L1379.66,161.897C1434.37,211.27 1439.1,256.641 1402.26,300.722L1464.32,300.722C1491.7,256.363 1488.58,210.593 1442.62,162.034Z"/> + </g> + </g> + <g id="flag"> + <g transform="matrix(-0.514348,0.857582,0.857582,0.514348,827.697,257.893)"> + <path d="M34.366,29.094C20.079,32.683 -19.154,57.638 -1.318,68.36C16.519,79.081 11.167,88.005 34.366,89.785C55.025,91.371 64.696,59.445 64.696,59.445C64.696,59.445 48.652,25.506 34.366,29.094Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M34.366,29.094C20.079,32.683 -19.154,57.638 -1.318,68.36C16.519,79.081 11.167,88.005 34.366,89.785C55.025,91.371 64.696,59.445 64.696,59.445C64.696,59.445 48.652,25.506 34.366,29.094Z" style="fill:rgb(241,229,0);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-0.514348,0.857582,0.857582,0.514348,898.91,652.272)"> + <path d="M33.509,29.601C19.222,33.19 -15.896,51.285 1.94,62.006C19.776,72.727 14.424,81.651 37.623,83.431C58.283,85.017 69.203,51.009 69.203,51.009C69.203,51.009 47.795,26.013 33.509,29.601Z" style="fill:rgb(241,229,0);fill-rule:nonzero;stroke:black;stroke-width:10px;"/> + </g> + <g transform="matrix(-0.948683,0.316228,0.316228,0.948683,967.218,234.906)"> + <path d="M58.001,18.377C37.285,17.936 -7.808,51.279 11.934,44.701C31.676,38.123 39.924,53.152 58.001,51.282C74.169,49.609 110.65,44.701 97.487,38.12C84.324,31.539 75.237,18.744 58.001,18.377Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M58.001,18.377C37.285,17.936 -7.808,51.279 11.934,44.701C31.676,38.123 39.924,53.152 58.001,51.282C74.169,49.609 110.65,44.701 97.487,38.12C84.324,31.539 75.237,18.744 58.001,18.377Z" style="fill:rgb(241,229,0);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-0.948683,-0.316228,0.316228,-0.948683,958.896,374.833)"> + <path d="M58.001,18.377C37.285,17.936 -7.808,51.279 11.934,44.701C31.676,38.123 39.924,53.152 58.001,51.282C74.169,49.609 110.65,44.701 97.487,38.12C84.324,31.539 75.237,18.744 58.001,18.377Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M58.001,18.377C37.285,17.936 -7.808,51.279 11.934,44.701C31.676,38.123 39.924,53.152 58.001,51.282C74.169,49.609 110.65,44.701 97.487,38.12C84.324,31.539 75.237,18.744 58.001,18.377Z" style="fill:rgb(241,229,0);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-0.724341,0.689442,0.689442,0.724341,966.846,580.751)"> + <path d="M53.22,60.027C32.504,59.586 -10.089,84.913 4.624,85.277C19.336,85.641 35.143,94.802 53.22,92.932C69.388,91.259 105.869,86.35 92.706,79.77C79.543,73.189 70.456,60.394 53.22,60.027Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M53.22,60.027C32.504,59.586 -10.089,84.913 4.624,85.277C19.336,85.641 35.143,94.802 53.22,92.932C69.388,91.259 105.869,86.35 92.706,79.77C79.543,73.189 70.456,60.394 53.22,60.027Z" style="fill:rgb(241,229,0);fill-rule:nonzero;"/> + </g> + <g transform="matrix(-0.993138,0.116948,-0.116948,-0.993138,1051.56,778.556)"> + <path d="M53.826,58.908C33.11,58.467 -11.39,80.039 9.277,82.473C29.945,84.907 35.749,93.682 53.826,91.812C69.994,90.14 106.474,85.231 93.311,78.65C80.148,72.07 71.062,59.274 53.826,58.908Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M53.826,58.908C33.11,58.467 -11.39,80.039 9.277,82.473C29.945,84.907 35.749,93.682 53.826,91.812C69.994,90.14 106.474,85.231 93.311,78.65C80.148,72.07 71.062,59.274 53.826,58.908Z" style="fill:rgb(241,229,0);fill-rule:nonzero;"/> + </g> + <g transform="matrix(1,0,0,1,22.9766,285.23)"> + <path d="M438.593,97.02C431.391,97.815 423.951,98.875 416.217,100.269C398.596,103.443 382.938,107.291 368.387,111.574C357.694,114.721 346.173,118.62 338.081,121.515C327.412,125.333 317.051,129.317 306.567,133.35C280.268,143.465 253.191,153.879 218.514,162.701C182.242,171.929 136.663,177.069 96.53,181.594C43.512,187.572 -0.001,192.479 0,204.323C0.002,225.133 117.956,256.896 197.703,277.161C244.091,288.949 282.074,290.387 319.768,291.814C354.941,293.146 389.863,294.469 431.128,304.181C510.861,322.946 562.163,364.061 642.416,390.725C643.317,391.025 644.224,391.323 645.136,391.621C732.306,420.02 885.344,447.845 885.344,447.845C885.344,447.845 833.984,305.622 822.032,214.726C814.675,158.783 817.056,90.526 812.906,55.477C811.37,42.5 799.401,30.486 799.401,30.486C799.401,30.486 748.211,53.7 684.117,73.67C680.025,74.946 674.109,76.74 671.05,77.647C657.248,81.744 643.613,85.443 630.777,88.416C620.643,90.764 550.363,97.038 543.749,96.906C534.748,96.726 525.887,96.348 516.998,95.969C515.462,95.904 513.925,95.838 512.386,95.773C501.801,95.329 461.079,94.539 438.593,97.02Z" style="fill:none;stroke:black;stroke-width:20px;"/> + <path d="M438.593,97.02C431.391,97.815 423.951,98.875 416.217,100.269C398.596,103.443 382.938,107.291 368.387,111.574C357.694,114.721 346.173,118.62 338.081,121.515C327.412,125.333 317.051,129.317 306.567,133.35C280.268,143.465 253.191,153.879 218.514,162.701C182.242,171.929 136.663,177.069 96.53,181.594C43.512,187.572 -0.001,192.479 0,204.323C0.002,225.133 117.956,256.896 197.703,277.161C244.091,288.949 282.074,290.387 319.768,291.814C354.941,293.146 389.863,294.469 431.128,304.181C510.861,322.946 562.163,364.061 642.416,390.725C643.317,391.025 644.224,391.323 645.136,391.621C732.306,420.02 885.344,447.845 885.344,447.845C885.344,447.845 833.984,305.622 822.032,214.726C814.675,158.783 817.056,90.526 812.906,55.477C811.37,42.5 799.401,30.486 799.401,30.486C799.401,30.486 748.211,53.7 684.117,73.67C680.025,74.946 674.109,76.74 671.05,77.647C657.248,81.744 643.613,85.443 630.777,88.416C620.643,90.764 550.363,97.038 543.749,96.906C534.748,96.726 525.887,96.348 516.998,95.969C515.462,95.904 513.925,95.838 512.386,95.773C501.801,95.329 461.079,94.539 438.593,97.02Z"/> + </g> + <g transform="matrix(1,0,0,1,22.9766,285.23)"> + <path d="M438.593,97.02C431.391,97.815 423.951,98.875 416.217,100.269C398.596,103.443 382.938,107.291 368.387,111.574C374.557,113.383 380.212,117.277 384.231,123.086C393.551,136.296 390.185,154.789 377.24,164.564C364.295,174.075 346.172,170.641 336.593,157.431C329.045,146.428 329.94,131.758 338.081,121.515C327.412,125.333 317.051,129.317 306.567,133.35C280.268,143.465 253.191,153.879 218.514,162.701C182.242,171.929 136.663,177.069 96.53,181.594C43.512,187.572 -0.001,192.479 0,204.323C0.002,225.133 117.956,256.896 197.703,277.161C244.091,288.949 282.074,290.387 319.768,291.814C354.941,293.146 389.863,294.469 431.128,304.181C432.279,300.169 432.904,295.949 432.904,291.64C470.445,328.099 531.286,331.269 569.345,299.566C588.762,281.073 602.484,254.653 595.235,222.158C590.057,199.702 568.309,176.717 541.383,176.717C520.153,176.717 503.843,193.889 499.959,215.025C498.924,223.479 498.406,244.086 515.493,253.861C508.503,252.804 503.584,249.634 500.736,246.992C493.746,240.387 490.38,232.197 489.603,224.536C487.532,207.363 493.487,190.191 504.361,178.566C537.5,142.108 596.529,157.431 618.795,204.193C633.034,234.047 634.329,267.599 621.384,297.452C630.704,294.811 641.06,295.339 650.898,299.302C674.976,309.341 686.368,337.081 676.788,361.651C670.557,377.213 657.367,387.687 642.416,390.725C643.317,391.025 644.224,391.323 645.136,391.621C732.306,420.02 885.344,447.845 885.344,447.845C885.344,447.845 833.984,305.622 822.032,214.726C815.093,161.962 817.287,92.376 812.906,55.477C810.923,38.769 799.401,30.486 799.401,30.486C799.401,30.486 748.211,53.7 684.117,73.67C698.862,72.389 712.624,83.674 714.847,99.045L714.588,99.045C716.659,115.424 705.785,130.748 689.733,132.597C674.199,135.239 659.442,123.614 657.112,107.499C655.557,95.201 661.299,83.499 671.05,77.647C657.248,81.744 643.613,85.443 630.777,88.416C627.233,100.852 618.887,111.37 607.662,117.538C615.429,120.708 623.714,124.671 631.998,129.427C683.779,161.13 707.597,232.461 699.313,282.658C687.662,266.806 662.29,263.372 644.167,275.26C654.005,223.479 623.455,169.584 576.853,153.732C550.963,147.392 521.448,150.298 499.183,174.075C483.648,190.719 475.881,221.629 490.898,244.878C502.548,263.372 525.073,267.599 544.749,259.145C552.516,255.71 569.862,244.614 568.05,224.8C571.157,231.669 571.416,237.481 570.639,241.444C568.827,250.691 563.649,257.824 557.694,262.579C544.749,274.204 527.92,278.431 512.386,275.789C463.713,266.806 444.296,207.892 470.962,163.243C488.568,132.861 518.341,113.047 552.775,109.348C549.135,105.767 546.083,101.586 543.749,96.906C534.748,96.726 525.887,96.348 516.998,95.969C515.462,95.904 513.925,95.838 512.386,95.773C515.311,98.53 518.611,100.9 522.225,102.743C473.034,120.18 442.742,174.075 452.839,222.95C460.089,249.37 477.176,273.147 508.762,281.073C530.768,286.885 560.542,277.902 572.969,253.332C582.549,234.047 575.04,211.59 558.212,198.645C551.48,193.361 533.357,183.586 517.306,194.946C521.448,188.87 526.367,185.699 530.251,184.378C539.312,181.737 547.856,182.265 554.846,184.907C570.898,190.719 582.807,203.4 588.503,219.252C605.332,266.014 565.202,312.776 513.94,311.719C477.694,311.19 444.813,293.225 424.619,262.315C421.253,269.712 416.075,276.317 409.085,281.337C387.855,296.66 358.6,291.376 343.583,269.712C328.826,248.049 334.004,218.195 355.234,203.136C370.25,192.568 389.668,191.776 404.943,200.494C404.425,196.003 404.425,191.248 404.425,186.229L404.425,186.228C404.838,153.239 418.7,121.892 438.593,97.02ZM596.255,372.757C589.856,364.306 586.286,353.9 586.432,343.158C580.079,348.344 572.686,353.354 564.679,357.753C574.825,362.802 585.21,367.837 596.255,372.757ZM737.63,180.944C747.949,179.705 752.197,168.649 751.093,162.451C749.539,153.732 741.513,147.392 732.97,148.448C724.685,149.769 718.212,157.959 719.507,166.942C720.801,175.66 728.827,182.001 737.63,180.944Z" style="fill:rgb(241,229,0);"/> + </g> + </g> + </g> +</svg> diff --git a/frontend/src/auth/UserRoute.tsx b/frontend/src/auth/UserRoute.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9d11d02e93e74c8c3fdc2dd9522022af33ee953a --- /dev/null +++ b/frontend/src/auth/UserRoute.tsx @@ -0,0 +1,34 @@ +import { Modal } from "@vseth/components"; +import React from "react"; +import { Route, RouteProps } from "react-router-dom"; +import { useUser } from "."; +import LoginCard from "../components/login-card"; +import LoadingOverlay from "../components/loading-overlay"; + +const UserRouteContent = <T extends RouteProps>({ props }: { props: T }) => { + const user = useUser(); + if (user !== undefined && !user.loggedin) { + return ( + <Modal isOpen={true}> + <LoginCard /> + </Modal> + ); + } else { + return ( + <> + <LoadingOverlay loading={user === undefined} /> + {user !== undefined && <Route {...props} />} + </> + ); + } +}; +const UserRoute = <T extends RouteProps>(props: T) => { + return ( + <Route + exact={props.exact} + path={props.path} + render={() => <UserRouteContent props={props} />} + /> + ); +}; +export default UserRoute; diff --git a/frontend/src/auth/index.tsx b/frontend/src/auth/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62edef6bdd488dfeba9f6b9334aa89888aff19fc --- /dev/null +++ b/frontend/src/auth/index.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; + +export interface User { + loggedin: boolean; + username: string; + displayname: string; + isAdmin: boolean; + isCategoryAdmin: boolean; + isExpert?: boolean; +} +export const notLoggedIn: User = { + loggedin: false, + isAdmin: false, + isCategoryAdmin: false, + username: "", + displayname: "Not Authorized", +}; +export const UserContext = createContext<User | undefined>(undefined); +export const useUser = () => useContext(UserContext); +export const SetUserContext = createContext<(user: User | undefined) => void>( + () => {}, +); +export const useSetUser = () => useContext(SetUserContext); diff --git a/frontend/src/colors.ts b/frontend/src/colors.ts deleted file mode 100644 index be9ff2af6ce006f290c9f90b1b8b7a845b31119b..0000000000000000000000000000000000000000 --- a/frontend/src/colors.ts +++ /dev/null @@ -1,38 +0,0 @@ -export default class Colors { - static readonly headerForeground = "#ffffff"; - static readonly headerBackground = "#394b59"; - static readonly headerShadow = "0 2px 4px 0 grey"; - - static readonly pageBackground = "#f3f3f3"; - - static readonly link = "#4b41ff"; - static readonly linkVisited = "#4b41ff"; - static readonly linkHover = "#ff6130"; - - static readonly inputBorder = "#aaaaaa"; - static readonly buttonBackground = "#d5d5d5"; - static readonly buttonBorder = "#aaaaaa"; - static readonly buttonBackgroundDisabled = "#888888"; - static readonly buttonBackgroundHover = "#aaaaaa"; - static readonly buttonPrimary = "#8cd6ff"; - static readonly buttonPrimaryHover = "#3980ff"; - - static readonly cardBackground = "#ffffff"; - static readonly cardHeader = "#a2a2a2"; - static readonly cardHeaderForeground = "#ffffff"; - static readonly cardShadow = "0 2px 4px 0 grey"; - - static readonly tableShadow = "0 2px 4px 0 grey"; - static readonly tableHeader = "#ffffff"; - static readonly tableHeaderBorder = "#bebebe"; - static readonly tableEven = "#ffffff"; - static readonly tableOdd = "#f7f7f7"; - - static readonly markdownBackground = "#ffffff"; - static readonly activeImage = "#8cd6ff"; - static readonly linkBannerBackground = "#dddddd"; - static readonly inactiveElement = "#b8b8b8"; - static readonly silentText = "#909090"; - static readonly selectionBackground = "rgba(108, 194, 255, 0.3)"; - static readonly commentBorder = "#e3e3e3"; -} diff --git a/frontend/src/components/Debug/DebugModal.tsx b/frontend/src/components/Debug/DebugModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be73d7773638401644ec8ec62abb14da5d497583 --- /dev/null +++ b/frontend/src/components/Debug/DebugModal.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { DebugOptions } from "."; +import { Modal, ModalHeader, ModalBody, InputField } from "@vseth/components"; +interface Props { + isOpen: boolean; + toggle: () => void; + debugOptions: DebugOptions; + setDebugOptions: (newOptions: DebugOptions) => void; +} +const DebugModal: React.FC<Props> = ({ + isOpen, + toggle, + debugOptions, + setDebugOptions, +}) => { + return ( + <Modal isOpen={isOpen} toggle={toggle}> + <ModalHeader>Debug</ModalHeader> + <ModalBody> + <InputField + type="checkbox" + label="Display all tooltips" + checked={debugOptions.displayAllTooltips} + onChange={e => + setDebugOptions({ + ...debugOptions, + displayAllTooltips: e.currentTarget.checked, + }) + } + /> + <InputField + type="checkbox" + label="Display canvas debugging indicators" + checked={debugOptions.displayCanvasType} + onChange={e => + setDebugOptions({ + ...debugOptions, + displayCanvasType: e.currentTarget.checked, + }) + } + /> + <InputField + type="checkbox" + label="Display snap regions" + checked={debugOptions.viewOptimalCutAreas} + onChange={e => + setDebugOptions({ + ...debugOptions, + viewOptimalCutAreas: e.currentTarget.checked, + }) + } + /> + </ModalBody> + </Modal> + ); +}; +export default DebugModal; diff --git a/frontend/src/components/Debug/index.ts b/frontend/src/components/Debug/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecb8ff16682827e65e9b9112668d3b6d6f48d63a --- /dev/null +++ b/frontend/src/components/Debug/index.ts @@ -0,0 +1,12 @@ +import { createContext } from "react"; +export interface DebugOptions { + displayAllTooltips: boolean; + displayCanvasType: boolean; + viewOptimalCutAreas: boolean; +} +export const defaultDebugOptions: DebugOptions = { + displayAllTooltips: false, + displayCanvasType: false, + viewOptimalCutAreas: false, +}; +export const DebugContext = createContext(defaultDebugOptions); diff --git a/frontend/src/components/Editor/BasicEditor.tsx b/frontend/src/components/Editor/BasicEditor.tsx index 014aec0e528e52f61bcaa87387f4db0586eae754..95ecea03ee8d9969962d47f16fde2be173890668 100644 --- a/frontend/src/components/Editor/BasicEditor.tsx +++ b/frontend/src/components/Editor/BasicEditor.tsx @@ -8,7 +8,7 @@ const wrapperStyle = css` `; const commonStyle = css` font-family: "Fira Code", monospace; - font-size: 14px; + font-size: 0.875rem; white-space: pre-wrap; word-wrap: break-word; width: 100%; @@ -40,6 +40,8 @@ interface Props { getSelectionRangeRef: React.RefObject<() => Range | undefined>; setSelectionRangeRef: React.RefObject<(newSelection: Range) => void>; + textareaElRef: React.MutableRefObject<HTMLTextAreaElement>; + onMetaKey: (str: string, shift: boolean) => boolean; } const BasicEditor: React.FC<Props> = ({ @@ -47,9 +49,9 @@ const BasicEditor: React.FC<Props> = ({ onChange, getSelectionRangeRef, setSelectionRangeRef, + textareaElRef, onMetaKey, }) => { - const textareaElRef = useRef<HTMLTextAreaElement>(null); const preElRef = useRef<HTMLPreElement>(null); // tslint:disable-next-line: no-any @@ -97,7 +99,7 @@ const BasicEditor: React.FC<Props> = ({ const preEl = preElRef.current; if (preEl === null) return; textareaEl.style.height = `${preEl.clientHeight}px`; - }, []); + }, [textareaElRef]); useEffect(() => { onResize(); @@ -106,7 +108,7 @@ const BasicEditor: React.FC<Props> = ({ return ( <div className={wrapperStyle}> <pre ref={preElRef} className={cx(commonStyle, preStyle)}> - {value + "\n"} + {`${value}\n`} </pre> <textarea value={value} diff --git a/frontend/src/components/Editor/Dropzone.tsx b/frontend/src/components/Editor/Dropzone.tsx index be3e6d3c5bf52e90e25a65cd0014828c6669196c..bf877c43ad68d487313d63c430335810480d46ed 100644 --- a/frontend/src/components/Editor/Dropzone.tsx +++ b/frontend/src/components/Editor/Dropzone.tsx @@ -1,15 +1,6 @@ import * as React from "react"; -import { css } from "emotion"; import { useCallback } from "react"; -const dropZoneStyle = css` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -`; - interface Props { onDragLeave: () => void; onDrop: (files: File[]) => void; @@ -54,7 +45,7 @@ const DropZone: React.FC<Props> = ({ onDragLeave, onDrop }) => { ); return ( <div - className={dropZoneStyle} + className="position-cover" onDragLeave={onDragLeaveHandler} onDrop={onDropHandler} onDragOver={onDragOverHandler} diff --git a/frontend/src/components/Editor/EditorFooter.tsx b/frontend/src/components/Editor/EditorFooter.tsx index 6b8173bdd3fd3a3215dd3a809fa9a4ff01dde189..626a97cc280795ccdb665c22dd61b61560cccd1b 100644 --- a/frontend/src/components/Editor/EditorFooter.tsx +++ b/frontend/src/components/Editor/EditorFooter.tsx @@ -1,46 +1,22 @@ -import * as React from "react"; +import { + Button, + ButtonGroup, + Modal, + ModalBody, + ModalHeader, +} from "@vseth/components"; import { css } from "emotion"; -import { useRef, useCallback } from "react"; -import { Image as ImageIcon, Plus } from "react-feather"; +import * as React from "react"; +import { useCallback, useRef, useState } from "react"; import { ImageHandle } from "./utils/types"; -const addImageIconStyle = css` - margin: 0; - border: none; - cursor: pointer; - background-color: transparent; - padding: 6px; -`; -const footerStyle = css` - border-top: 1px solid rgba(0, 0, 0, 0.1); -`; const rowStyle = css` - display: flex; - flex-direction: row; - align-items: center; -`; -const spacer = css` - flex-grow: 1; + text-align: right; `; const fileInputStyle = css` visibility: hidden; display: none; `; -const addImageButtonStyle = css` - border: none; - background-color: transparent; - cursor: pointer; - display: flex; - align-items: center; - color: rgba(0, 0, 0, 0.4); - transition: color 0.1s; - &:hover { - color: rgba(0, 0, 0, 0.8); - } -`; -const addImageTextStyle = css` - font-size: 12px; -`; interface Props { onFiles: (files: File[]) => void; onOpenOverlay: () => void; @@ -53,7 +29,6 @@ const EditorFooter: React.FC<Props> = ({ onDelete, onOpenOverlay, }) => { - const iconSize = 15; const fileInputRef = useRef<HTMLInputElement>(null); const onFile = useCallback(() => { @@ -78,26 +53,22 @@ const EditorFooter: React.FC<Props> = ({ }, [onFiles], ); + const [isHelpOpen, setIsHelpOpen] = useState(false); + const toggleHelp = useCallback(() => setIsHelpOpen(prev => !prev), []); return ( - <div className={footerStyle}> + <div> <div className={rowStyle}> - <div className={spacer} /> - <button - onClick={onOpenOverlay} - className={addImageButtonStyle} - type="button" - > - <div className={addImageIconStyle}> - <ImageIcon size={iconSize} /> - </div> - <div className={addImageTextStyle}>Browse Images</div> - </button> - <button onClick={onFile} className={addImageButtonStyle} type="button"> - <div className={addImageIconStyle}> - <Plus size={iconSize} /> - </div> - <div className={addImageTextStyle}>Add Image</div> - </button> + <ButtonGroup> + <Button size="sm" onClick={toggleHelp}> + Help + </Button> + <Button size="sm" onClick={onOpenOverlay}> + Browse Images + </Button> + <Button size="sm" onClick={onFile}> + Add Image + </Button> + </ButtonGroup> <input type="file" className={fileInputStyle} @@ -105,10 +76,13 @@ const EditorFooter: React.FC<Props> = ({ onChange={onChangeHandler} /> </div> - <small> - You can use Markdown. Use ``` code ``` for code. Use $ math $ or $$ \n - math \n $$ for latex math. - </small> + <Modal isOpen={isHelpOpen} toggle={toggleHelp}> + <ModalHeader>Help</ModalHeader> + <ModalBody> + You can use Markdown. Use ``` code ``` for code. Use $ math $ or $$ \n + math \n $$ for latex math. + </ModalBody> + </Modal> </div> ); }; diff --git a/frontend/src/components/Editor/EditorHeader.tsx b/frontend/src/components/Editor/EditorHeader.tsx index ace39b83838b2f59f2d19fd308fe39c684a77b5d..0d099ac8c1751b0385eaef61423f9fe5205fa1e4 100644 --- a/frontend/src/components/Editor/EditorHeader.tsx +++ b/frontend/src/components/Editor/EditorHeader.tsx @@ -1,28 +1,37 @@ -import { EditorMode } from "./utils/types"; -import * as React from "react"; -import TabBar from "./TabBar"; +import { Nav, NavItem, NavLink } from "@vseth/components"; import { css } from "emotion"; -import { Bold, Italic, Link, Code, DollarSign } from "react-feather"; +import * as React from "react"; +import { Bold, Code, DollarSign, Italic, Link } from "react-feather"; +import TooltipButton from "../TooltipButton"; +import { EditorMode } from "./utils/types"; const iconButtonStyle = css` margin: 0; border: none; cursor: pointer; background-color: transparent; - padding: 6px; - color: rgba(0, 0, 0, 0.4); + padding: 0 0.875rem; + height: 100%; + color: rgba(0, 0, 0, 0.8); transition: color 0.1s; + min-width: 0; &:hover { color: rgba(0, 0, 0, 0.8); } `; -const headerStyle = css` - border-bottom: 1px solid rgba(0, 0, 0, 0.1); +const navStyle = css` + width: 100%; display: flex; - flex-direction: row; - align-items: flex-end; + justify-content: space-between; `; -const spacer = css` +const headerStyle = css` + position: relative; +`; +const linkStyle = css` + font-size: 0.8rem !important; +`; +const tabContainer = css` + display: flex; flex-grow: 1; `; @@ -44,60 +53,87 @@ const EditorHeader: React.FC<Props> = ({ const iconSize = 15; return ( <div className={headerStyle}> - <TabBar - items={[ - { - title: "Write", - active: activeMode === "write", - onClick: () => onActiveModeChange("write"), - }, - { - title: "Preview", - active: activeMode === "preview", - onClick: () => onActiveModeChange("preview"), - }, - ]} - /> - <div className={spacer} /> - {activeMode === "write" && ( - <> - <button - className={iconButtonStyle} - onClick={handlers.onMathClick} - type="button" - > - <DollarSign size={iconSize} /> - </button> - <button - className={iconButtonStyle} - onClick={handlers.onCodeClick} - type="button" - > - <Code size={iconSize} /> - </button> - <button - className={iconButtonStyle} - onClick={handlers.onLinkClick} - type="button" - > - <Link size={iconSize} /> - </button> - <button - className={iconButtonStyle} - onClick={handlers.onItalicClick} - type="button" - > - <Italic size={iconSize} /> - </button> - <button - className={iconButtonStyle} - onClick={handlers.onBoldClick} - type="button" - > - <Bold size={iconSize} /> - </button> - </> - )} + <Nav tabs className={navStyle}> + <div className={tabContainer}> + <NavItem> + <NavLink + active={activeMode === "write"} + onClick={() => onActiveModeChange("write")} + className={linkStyle} + > + Write + </NavLink> + </NavItem> + <NavItem> + <NavLink + active={activeMode === "preview"} + onClick={() => onActiveModeChange("preview")} + className={linkStyle} + > + Preview + </NavLink> + </NavItem> + </div> + <div> + {activeMode === "write" && ( + <> + <TooltipButton + className={iconButtonStyle} + onClick={handlers.onMathClick} + type="button" + size="sm" + tooltip="Inline Math" + > + <DollarSign size={iconSize} /> + </TooltipButton> + <TooltipButton + className={iconButtonStyle} + onClick={handlers.onCodeClick} + type="button" + size="sm" + tooltip="Code Block" + > + <Code size={iconSize} /> + </TooltipButton> + <TooltipButton + className={iconButtonStyle} + onClick={handlers.onLinkClick} + type="button" + size="sm" + tooltip="Hyperlink" + > + <Link size={iconSize} /> + </TooltipButton> + <TooltipButton + className={iconButtonStyle} + onClick={handlers.onItalicClick} + type="button" + size="sm" + tooltip={ + <> + Italic<kbd>Ctrl + I</kbd>{" "} + </> + } + > + <Italic size={iconSize} /> + </TooltipButton> + <TooltipButton + className={iconButtonStyle} + onClick={handlers.onBoldClick} + type="button" + size="sm" + tooltip={ + <> + Bold<kbd>Ctrl + B</kbd>{" "} + </> + } + > + <Bold size={iconSize} /> + </TooltipButton> + </> + )} + </div> + </Nav> </div> ); }; diff --git a/frontend/src/components/Editor/TabBar.tsx b/frontend/src/components/Editor/TabBar.tsx deleted file mode 100644 index 576061eded7616614fa81509bb13dcb25a218858..0000000000000000000000000000000000000000 --- a/frontend/src/components/Editor/TabBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import * as React from "react"; -import { css, cx } from "emotion"; - -const buttonStyle = css` - border: none; - background-color: transparent; - cursor: pointer; - padding: 0.4em; - font-weight: 400; - font-size: 14px; - margin: 0; - color: rgba(0, 0, 0, 0.5); - transition: color 0.1s, border-bottom 0.1s; - border-radius: 0; - &:hover { - background-color: transparent; - } -`; -const inactiveButtonStyle = css` - border-bottom: 1px solid transparent; - &:hover { - color: rgba(0, 0, 0, 0.9); - border-bottom: 1px solid rgba(0, 0, 0, 0.3); - } -`; -const activeButtonStyle = css` - color: rgba(0, 0, 0, 1); - border-bottom: 1px solid #3867d6; -`; - -interface Item { - title: string; - active: boolean; - onClick: () => void; -} -interface Props { - items: Item[]; -} -const TabBar: React.FC<Props> = ({ items }) => { - return ( - <div> - {items.map(item => ( - <button - key={item.title} - onClick={item.onClick} - className={cx( - buttonStyle, - item.active ? activeButtonStyle : inactiveButtonStyle, - )} - type="button" - > - {item.title} - </button> - ))} - </div> - ); -}; -export default TabBar; diff --git a/frontend/src/components/Editor/index.tsx b/frontend/src/components/Editor/index.tsx index 01dd6e28e60395595235a215172ca64995af4672..ddaf4927bb542341d61e52306802d31ad3d3fdce 100644 --- a/frontend/src/components/Editor/index.tsx +++ b/frontend/src/components/Editor/index.tsx @@ -1,26 +1,17 @@ -import * as React from "react"; -import { useCallback, useState, useRef } from "react"; import { css, cx } from "emotion"; -import { Range, EditorMode, ImageHandle } from "./utils/types"; +import * as React from "react"; +import { useCallback, useRef, useState } from "react"; +import ImageOverlay from "../image-overlay"; import BasicEditor from "./BasicEditor"; -import EditorHeader from "./EditorHeader"; import DropZone from "./Dropzone"; import EditorFooter from "./EditorFooter"; -import ImageOverlay from "../image-overlay"; -import { UndoStack, push, undo, redo } from "./utils/undo-stack"; +import EditorHeader from "./EditorHeader"; +import { EditorMode, ImageHandle, Range } from "./utils/types"; +import { push, redo, undo, UndoStack } from "./utils/undo-stack"; const editorWrapperStyle = css` padding: 1.2em; `; - -const wrapperStyle = css` - position: relative; - border: 1px solid transparent; -`; -const dragHoveredWrapperStyle = css` - border: 1px solid #fed330; - border-radius: 3px; -`; interface Props { value: string; onChange: (newValue: string) => void; @@ -42,7 +33,9 @@ const Editor: React.FC<Props> = ({ const [isDragHovered, setIsDragHovered] = useState(false); const [attachments, setAttachments] = useState<ImageHandle[]>([]); const [overlayOpen, setOverlayOpen] = useState(false); - + const textareaElRef = useRef<HTMLTextAreaElement>( + null, + ) as React.MutableRefObject<HTMLTextAreaElement>; const setCurrent = useCallback( (newValue: string, newSelection?: Range) => { if (newSelection) setSelectionRangeRef.current(newSelection); @@ -70,7 +63,7 @@ const Editor: React.FC<Props> = ({ const before = value.substring(0, selection.start); const content = value.substring(selection.start, selection.end); const after = value.substring(selection.end); - const newContent = "`; + const newContent = ``; const newSelection = { start: selection.start + 2, end: selection.start + content.length + 2, @@ -86,7 +79,7 @@ const Editor: React.FC<Props> = ({ const before = value.substring(0, selection.start); const content = value.substring(selection.start, selection.end); const after = value.substring(selection.end); - const newContent = "[" + content + "](https://www.example.com)"; + const newContent = `[${content}](https://www.example.com)`; const newSelection = { start: selection.start + content.length + 3, end: selection.start + newContent.length - 1, @@ -153,7 +146,7 @@ const Editor: React.FC<Props> = ({ const selection = getSelectionRangeRef.current(); if (selection === undefined) return true; const [newState, newStack] = undo(undoStack, { - value: value, + value, selection, time: new Date(), }); @@ -167,7 +160,7 @@ const Editor: React.FC<Props> = ({ const selection = getSelectionRangeRef.current(); if (selection === undefined) return true; const [newState, newStack] = redo(undoStack, { - value: value, + value, selection, time: new Date(), }); @@ -231,45 +224,47 @@ const Editor: React.FC<Props> = ({ }, []); return ( - <> - <div - className={cx(wrapperStyle, isDragHovered && dragHoveredWrapperStyle)} - onDragEnter={onDragEnter} - > - <EditorHeader - activeMode={mode} - onActiveModeChange={setMode} - onMathClick={onMathClick} - onCodeClick={onCodeClick} - onLinkClick={onLinkClick} - onItalicClick={onItalicClick} - onBoldClick={onBoldClick} - /> - <div className={editorWrapperStyle}> - {mode === "write" ? ( - <BasicEditor - value={value} - onChange={newValue => setCurrent(newValue)} - setSelectionRangeRef={setSelectionRangeRef} - getSelectionRangeRef={getSelectionRangeRef} - onMetaKey={onMetaKey} - /> - ) : ( - preview(value) - )} - </div> - <EditorFooter - onFiles={onFiles} - attachments={attachments} - onDelete={onDeleteAttachment} - onOpenOverlay={onOpenOverlay} - /> - {isDragHovered && ( - <DropZone onDragLeave={onDragLeave} onDrop={onFiles} /> + <div + className={cx("form-control", isDragHovered && "border-primary")} + onClick={() => textareaElRef.current && textareaElRef.current.focus()} + onDragEnter={onDragEnter} + > + <EditorHeader + activeMode={mode} + onActiveModeChange={setMode} + onMathClick={onMathClick} + onCodeClick={onCodeClick} + onLinkClick={onLinkClick} + onItalicClick={onItalicClick} + onBoldClick={onBoldClick} + /> + <div className={editorWrapperStyle}> + {mode === "write" ? ( + <BasicEditor + textareaElRef={textareaElRef} + value={value} + onChange={newValue => setCurrent(newValue)} + setSelectionRangeRef={setSelectionRangeRef} + getSelectionRangeRef={getSelectionRangeRef} + onMetaKey={onMetaKey} + /> + ) : ( + preview(value) )} </div> - {overlayOpen && <ImageOverlay onClose={onImageDialogClose} />} - </> + <EditorFooter + onFiles={onFiles} + attachments={attachments} + onDelete={onDeleteAttachment} + onOpenOverlay={onOpenOverlay} + /> + {isDragHovered && <DropZone onDragLeave={onDragLeave} onDrop={onFiles} />} + <ImageOverlay + isOpen={overlayOpen} + toggle={() => onImageDialogClose("")} + closeWithImage={onImageDialogClose} + /> + </div> ); }; export default Editor; diff --git a/frontend/src/components/TooltipButton.tsx b/frontend/src/components/TooltipButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5f7a7f35e2c235965acf9c0a1455ed72ba708a6 --- /dev/null +++ b/frontend/src/components/TooltipButton.tsx @@ -0,0 +1,63 @@ +import { useClickAway } from "@umijs/hooks"; +import { Button, ButtonProps, Tooltip } from "@vseth/components"; +import React, { useCallback, useState, useContext, useEffect } from "react"; +import useLongPress from "../hooks/useLongPress"; +import { DebugContext } from "./Debug"; + +function detectMobile() { + const toMatch = [ + /Android/i, + /webOS/i, + /iPhone/i, + /iPad/i, + /iPod/i, + /BlackBerry/i, + /Windows Phone/i, + ]; + + return toMatch.some(toMatchItem => { + return navigator.userAgent.match(toMatchItem); + }); +} +const isMobile = detectMobile(); + +export interface TooltipButtonProps extends ButtonProps { + tooltip?: React.ReactNode; +} +let id = 0; +const TooltipButton: React.FC<TooltipButtonProps> = ({ + tooltip, + onClick, + children, + ...buttonProps +}) => { + const { displayAllTooltips } = useContext(DebugContext); + const [open, setState] = useState(false); + const toggle = useCallback(() => setState(a => !a), []); + const [buttonId] = useState(() => id++); + const longPress = useLongPress( + () => isMobile && setState(true), + onClick ?? (() => {}), + ); + const ref = useClickAway(() => setState(false)); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return ( + <> + <Button {...longPress} id={`btn-${buttonId}`} {...buttonProps}> + <span ref={ref} /> {children} + {mounted && tooltip && ( + <Tooltip + isOpen={open || displayAllTooltips} + target={`btn-${buttonId}`} + toggle={() => !isMobile && toggle()} + delay={{ show: 800, hide: 100 }} + > + {tooltip} + </Tooltip> + )} + </Button> + </> + ); +}; +export default TooltipButton; diff --git a/frontend/src/components/answer-section.tsx b/frontend/src/components/answer-section.tsx index 642fa0f53090c28263bc0d961650f5d716a8485a..f510598b61632bcbadfc4b0b114ce3c880ce2efd 100644 --- a/frontend/src/components/answer-section.tsx +++ b/frontend/src/components/answer-section.tsx @@ -1,338 +1,328 @@ -import * as React from "react"; -import { AnswerSection, SectionKind } from "../interfaces"; -import { loadAnswerSection } from "../exam-loader"; -import { fetchPost } from "../fetch-utils"; -import { css } from "glamor"; +import styled from "@emotion/styled"; +import { + Button, + ButtonDropdown, + ButtonGroup, + Card, + CardFooter, + CardHeader, + Container, + DropdownItem, + DropdownMenu, + DropdownToggle, + Icon, + ICONS, + Input, + InputGroup, + InputGroupButtonDropdown, + Spinner, + UncontrolledDropdown, + Col, + Row, +} from "@vseth/components"; +import React, { useCallback, useEffect, useState } from "react"; +import { useAnswers, useRemoveSplit } from "../api/hooks"; +import { useUser } from "../auth"; +import useInitialState from "../hooks/useInitialState"; +import useLoad from "../hooks/useLoad"; +import { AnswerSection } from "../interfaces"; import AnswerComponent from "./answer"; -import GlobalConsts from "../globalconsts"; -import { Edit } from "react-feather"; -import { Link } from "react-router-dom"; -import moment from "moment"; +import IconButton from "./icon-button"; +import ThreeButtons from "./three-columns"; + +const NameCard = styled(Card)` + border-top-left-radius: 0; + border-top-right-radius: 0; +`; + +const AnswerSectionButtonWrapper = styled(Card)` + margin-top: 1em; + margin-bottom: 1em; +`; + +interface AddButtonProps { + allowAnswer: boolean; + allowLegacyAnswer: boolean; + hasAnswerDraft: boolean; + hasLegacyAnswerDraft: boolean; + onAnswer: () => void; + onLegacyAnswer: () => void; +} +const AddButton: React.FC<AddButtonProps> = ({ + allowAnswer, + allowLegacyAnswer, + hasAnswerDraft, + hasLegacyAnswerDraft, + onAnswer, + onLegacyAnswer, +}) => { + const [isOpen, setOpen] = useState(false); + const toggle = useCallback(() => setOpen(old => !old), []); + if (allowAnswer && allowLegacyAnswer) { + return ( + <ButtonDropdown isOpen={isOpen} toggle={toggle}> + <DropdownToggle size="sm" caret> + Add Answer + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={onAnswer} disabled={hasAnswerDraft}> + Add Answer + </DropdownItem> + <DropdownItem + onClick={onLegacyAnswer} + disabled={hasLegacyAnswerDraft} + > + Add Legacy Answer + </DropdownItem> + </DropdownMenu> + </ButtonDropdown> + ); + } else { + return ( + <ButtonGroup> + {allowAnswer && ( + <Button size="sm" onClick={onAnswer} disabled={hasAnswerDraft}> + Add Answer + </Button> + )} + {allowLegacyAnswer && ( + <Button + size="sm" + onClick={onLegacyAnswer} + disabled={hasLegacyAnswerDraft} + > + Add Legacy Answer + </Button> + )} + </ButtonGroup> + ); + } +}; interface Props { - name: string; - moveEnabled: boolean; - isMoveTarget: boolean; - moveTargetChange: (wantsToBeMoved: boolean) => void; - isAdmin: boolean; - isExpert: boolean; - filename: string; oid: string; - width: number; - canDelete: boolean; onSectionChange: () => void; - onCutNameChange: (newName: string) => void; onToggleHidden: () => void; hidden: boolean; cutVersion: number; -} - -interface State { - name: string; - editingName: boolean; - section?: AnswerSection; - addingAnswer: boolean; - addingLegacyAnswer: boolean; -} - -const styles = { - wrapper: css({ - width: "80%", - margin: "20px auto", - "@media (max-width: 699px)": { - width: "95%", - }, - }), - threebuttons: css({ - textAlign: "center", - display: "flex", - justifyContent: "space-between", - "& > div": { - width: ["200px", "calc(100% / 3)"], - }, - }), - leftButton: css({ - textAlign: "left", - }), - rightButton: css({ - textAlign: "right", - }), - answerWrapper: css({ - marginBottom: "10px", - }), - divideLine: css({ - width: "100%", - height: "1px", - margin: "0", - backgroundColor: "black", - position: "relative", - bottom: "20px", - zIndex: "-100", - "@media (max-width: 699px)": { - display: "none", - }, - }), - namePart: css({ - display: "inline-block", - backgroundColor: "#dadada", - padding: "0.25rem", - margin: "0.2rem", - borderRadius: "3px", - }), -}; - -export default class AnswerSectionComponent extends React.Component< - Props, - State -> { - constructor(props: Props) { - super(props); - this.state = { - name: this.props.name, - editingName: false, - addingAnswer: false, - addingLegacyAnswer: false, - }; - } + setCutVersion: (newVersion: number) => void; - componentDidMount() { - loadAnswerSection(this.props.oid) - .then(res => { - this.setState({ section: res }); - const hash = window.location.hash.substr(1); - const hashAnswer = res.answers.find(answer => answer.longId === hash); - if (hashAnswer) { - this.props.onToggleHidden(); - if (hashAnswer.divRef) { - hashAnswer.divRef.scrollIntoView(); - } - } - }) - .catch(() => undefined); - } + cutName: string; + onCutNameChange: (newName: string) => void; - componentDidUpdate(prevProps: Readonly<Props>) { - if (prevProps.cutVersion !== this.props.cutVersion) { - loadAnswerSection(this.props.oid) - .then(res => { - this.setState({ section: res }); - }) - .catch(() => undefined); - } - } + onCancelMove: () => void; + onMove: () => void; + isBeingMoved: boolean; +} - removeSection = async () => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove answer section with all answers?"); - if (confirmation) { - fetchPost(`/api/exam/removecut/${this.props.oid}/`, {}).then(() => { - this.props.onSectionChange(); - }); - } - }; - moveSection = () => { - this.props.moveTargetChange(!this.props.isMoveTarget); - }; +const AnswerSectionComponent: React.FC<Props> = React.memo( + ({ + oid, + onSectionChange, + onToggleHidden, + hidden, + cutVersion, + setCutVersion, - addAnswer = (legacy: boolean) => { - this.setState({ - addingAnswer: true, - addingLegacyAnswer: legacy, - }); - if (this.props.hidden) { - this.props.onToggleHidden(); - } - }; + cutName, + onCutNameChange, - // takes the parsed json for the answersection which was returned from the server - onSectionChanged = (res: { value: AnswerSection }) => { - const answersection = res.value; - //answersection.key = this.props.oid; - answersection.kind = SectionKind.Answer; - this.setState({ - section: answersection, - addingAnswer: false, - addingLegacyAnswer: false, + onCancelMove, + onMove, + isBeingMoved, + }) => { + const [data, setData] = useState<AnswerSection | undefined>(); + const run = useAnswers(oid, data => { + setData(data); + setCutVersion(data.cutVersion); }); - }; - - onCancelEdit = () => { - this.setState({ - addingAnswer: false, - addingLegacyAnswer: false, + const runRemoveSplit = useRemoveSplit(oid, () => { + if (isBeingMoved) onCancelMove(); + onSectionChange(); }); - }; - - updateName = async () => { - try { - await fetchPost(`/api/exam/editcut/${this.props.oid}/`, { - name: this.state.name, - }); - this.setState({ - editingName: false, - }); - this.props.onCutNameChange(this.state.name); - } catch (e) { - return; - } - }; - - render() { - const { section } = this.state; - if (!section) { - return <div>Loading...</div>; - } - const nameParts = this.state.name.split(" > "); - const id = `${this.props.oid}-${nameParts.join("-")}`; - const name = ( - <div> - {this.state.editingName ? ( - <> - <input - type="text" - value={this.state.name || ""} - placeholder="Name" - onChange={e => this.setState({ name: e.target.value })} - /> - <button onClick={this.updateName}>Save</button> - </> - ) : ( - <> - {this.state.name.length > 0 && ( - <Link to={`#${encodeURI(id)}`} id={id}> - {nameParts.map((part, i) => ( - <div {...styles.namePart} key={part + i}> - {part} - </div> - ))} - </Link> - )} - {this.props.canDelete && ( - <button onClick={() => this.setState({ editingName: true })}> - <Edit size={12} /> - </button> - )} - </> - )} - </div> + const setAnswerSection = useCallback( + (newData: AnswerSection) => { + setCutVersion(newData.cutVersion); + setData(newData); + }, + [setCutVersion], ); - const editingSection = ( - <div {...styles.rightButton}> - {this.props.canDelete && ( - <button onClick={this.removeSection}>Remove</button> - )} - {this.props.moveEnabled && this.props.canDelete && ( - <button onClick={this.moveSection}> - {this.props.isMoveTarget ? "Cancel" : "Move"} - </button> - )} - </div> + const [inViewport, ref] = useLoad<HTMLDivElement>(); + const visible = inViewport || false; + useEffect(() => { + if ( + (data === undefined || data.cutVersion !== cutVersion) && + (visible || !hidden) + ) { + run(); + } + }, [data, visible, run, cutVersion, hidden]); + const [hasDraft, setHasDraft] = useState(false); + const [hasLegacyDraft, setHasLegacyDraft] = useState(false); + const onAddAnswer = useCallback(() => { + setHasDraft(true); + if (hidden) onToggleHidden(); + }, [hidden, onToggleHidden]); + const onAddLegacyAnswer = useCallback(() => { + setHasLegacyDraft(true); + if (hidden) onToggleHidden(); + }, [hidden, onToggleHidden]); + const user = useUser()!; + const isCatAdmin = user.isCategoryAdmin; + + const [draftName, setDraftName] = useInitialState(cutName); + const [isEditingName, setIsEditingName] = useState( + data && cutName.length === 0 && isCatAdmin, ); - if (this.props.hidden && section.answers.length > 0) { - return ( - <div {...styles.wrapper}> - {name} - <div key="showhidebutton" {...styles.threebuttons}> - <div /> - <div> - <button onClick={this.props.onToggleHidden}>Show Answers</button> - </div> - {editingSection} - </div> - <div {...styles.divideLine} /> - </div> - ); - } + useEffect(() => { + if (data && cutName.length === 0 && isCatAdmin) setIsEditingName(true); + }, [data, isCatAdmin, cutName]); + const nameParts = cutName.split(" > "); + const id = `${oid}-${nameParts.join("-")}`; + return ( - <div {...styles.wrapper}> - {name} - {(section.answers.length > 0 || this.state.addingAnswer) && ( - <div {...styles.answerWrapper}> - {this.state.addingAnswer && ( - <AnswerComponent - isReadonly={false} - isAdmin={this.props.isAdmin} - isExpert={this.props.isExpert} - filename={this.props.filename} - sectionId={this.props.oid} - answer={{ - oid: "", - longId: "", - upvotes: 1, - expertvotes: 0, - authorId: "", - authorDisplayName: this.state.addingLegacyAnswer - ? "New Legacy Answer" - : "New Answer", - canEdit: true, - isUpvoted: true, - isDownvoted: false, - isExpertVoted: false, - isFlagged: false, - flagged: 0, - comments: [], - text: "", - time: moment().format(GlobalConsts.momentParseString), - edittime: moment().format(GlobalConsts.momentParseString), - filename: this.props.filename, - sectionId: this.props.oid, - isLegacyAnswer: this.state.addingLegacyAnswer, - }} - onSectionChanged={this.onSectionChanged} - onCancelEdit={this.onCancelEdit} - /> - )} - {section.answers.map(e => ( - <AnswerComponent - key={e.oid} - isReadonly={false} - isAdmin={this.props.isAdmin} - isExpert={this.props.isExpert} - answer={e} - filename={this.props.filename} - sectionId={this.props.oid} - onSectionChanged={this.onSectionChanged} - onCancelEdit={this.onCancelEdit} - /> - ))} - </div> + <> + {data && ((data.name && data.name.length > 0) || isCatAdmin) && ( + <NameCard id={id}> + <CardFooter> + {isEditingName ? ( + <InputGroup size="sm"> + <Input + type="text" + value={draftName} + placeholder="Name" + onChange={e => setDraftName(e.target.value)} + /> + <InputGroupButtonDropdown addonType="append"> + <IconButton + tooltip="Save PDF section name" + icon="SAVE" + block + onClick={() => { + setIsEditingName(false); + onCutNameChange(draftName); + }} + /> + </InputGroupButtonDropdown> + </InputGroup> + ) : ( + <Row> + <Col className="d-flex flex-center flex-column"> + <h6 className="m-0">{cutName}</h6> + </Col> + <Col xs="auto"> + {isCatAdmin && ( + <IconButton + tooltip="Edit PDF section name" + size="sm" + icon="EDIT" + onClick={() => setIsEditingName(true)} + /> + )} + </Col> + </Row> + )} + </CardFooter> + </NameCard> )} - <div key="showhidebutton" {...styles.threebuttons}> - <div {...styles.leftButton}> - {(section.allow_new_answer || section.allow_new_legacy_answer) && - !this.state.addingAnswer && ( - <div> - <button - className="primary" - title={ - section.allow_new_answer && - section.allow_new_legacy_answer - ? "Hold Shift to add a Legacy Answer" - : undefined - } - onClick={ev => - this.addAnswer( - !section.allow_new_answer || - (section.allow_new_legacy_answer && ev.shiftKey), - ) - } - > - {section.allow_new_answer - ? "Add Answer" - : "Add Legacy Answer"} - </button> - </div> + <Container fluid> + {!hidden && data && ( + <> + {data.answers.map(answer => ( + <AnswerComponent + key={answer.oid} + section={data} + answer={answer} + onSectionChanged={setAnswerSection} + isLegacyAnswer={answer.isLegacyAnswer} + /> + ))} + {hasDraft && ( + <AnswerComponent + section={data} + onSectionChanged={setAnswerSection} + onDelete={() => setHasDraft(false)} + isLegacyAnswer={false} + /> )} - </div> - <div> - {section.answers.length > 0 && ( - <button onClick={this.props.onToggleHidden}>Hide Answers</button> - )} - </div> - {editingSection} - </div> - <div {...styles.divideLine} /> - </div> + {hasLegacyDraft && ( + <AnswerComponent + section={data} + onSectionChanged={setAnswerSection} + onDelete={() => setHasLegacyDraft(false)} + isLegacyAnswer={true} + /> + )} + </> + )} + <AnswerSectionButtonWrapper + color={isBeingMoved ? "primary" : undefined} + > + <CardHeader> + <div className="d-flex" ref={ref}> + {data === undefined ? ( + <ThreeButtons center={<Spinner />} /> + ) : ( + <> + <ThreeButtons + left={ + isBeingMoved ? ( + <Button size="sm" onClick={onCancelMove}> + Cancel + </Button> + ) : ( + (data.answers.length === 0 || !hidden) && + data && ( + <AddButton + allowAnswer={data.allow_new_answer} + allowLegacyAnswer={ + data.allow_new_legacy_answer && isCatAdmin + } + hasAnswerDraft={hasDraft} + hasLegacyAnswerDraft={hasLegacyDraft} + onAnswer={onAddAnswer} + onLegacyAnswer={onAddLegacyAnswer} + /> + ) + ) + } + center={ + !isBeingMoved && + data.answers.length > 0 && ( + <Button + color="primary" + size="sm" + onClick={onToggleHidden} + > + {hidden ? "Show Answers" : "Hide Answers"} + </Button> + ) + } + right={ + isCatAdmin && ( + <UncontrolledDropdown> + <DropdownToggle caret size="sm"> + <Icon icon={ICONS.DOTS_H} size={18} /> + </DropdownToggle> + <DropdownMenu> + <DropdownItem onClick={runRemoveSplit}> + Delete + </DropdownItem> + <DropdownItem onClick={onMove}>Move</DropdownItem> + </DropdownMenu> + </UncontrolledDropdown> + ) + } + /> + </> + )} + </div> + </CardHeader> + </AnswerSectionButtonWrapper> + </Container> + </> ); - } -} + }, +); + +export default AnswerSectionComponent; diff --git a/frontend/src/components/answer.tsx b/frontend/src/components/answer.tsx index 4a599733e6584d52e31d9ad60204d3f1999f817d..b50165d77ce0f9ccbc7abd5a59057c9161585a40 100644 --- a/frontend/src/components/answer.tsx +++ b/frontend/src/components/answer.tsx @@ -1,558 +1,324 @@ -import * as React from "react"; +import styled from "@emotion/styled"; +import { + ButtonDropdown, + ButtonGroup, + ButtonToolbar, + Card, + CardBody, + CardHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, + Icon, + ICONS, + Row, + Col, +} from "@vseth/components"; +import { css } from "emotion"; +import React, { useCallback, useState } from "react"; +import { imageHandler } from "../api/fetch-utils"; +import { + useRemoveAnswer, + useResetFlaggedVote, + useSetExpertVote, + useSetFlagged, + useUpdateAnswer, +} from "../api/hooks"; +import { useUser } from "../auth"; +import useConfirm from "../hooks/useConfirm"; import { Answer, AnswerSection } from "../interfaces"; -import moment from "moment"; -import Comment from "./comment"; -import { css } from "glamor"; -import MarkdownText from "./markdown-text"; -import { fetchPost, imageHandler } from "../fetch-utils"; -import Colors from "../colors"; -import { Link } from "react-router-dom"; -import globalcss from "../globalcss"; -import GlobalConsts from "../globalconsts"; -import colors from "../colors"; +import { copy } from "../utils/clipboard"; +import CommentSectionComponent from "./comment-section"; import Editor from "./Editor"; import { UndoStack } from "./Editor/utils/undo-stack"; +import IconButton from "./icon-button"; +import MarkdownText from "./markdown-text"; +import Score from "./score"; +import SmallButton from "./small-button"; -interface Props { - isReadonly: boolean; - isAdmin: boolean; - isExpert: boolean; - filename: string; - sectionId: string; - answer: Answer; - onSectionChanged: (res: { value: AnswerSection }) => void; - onCancelEdit: () => void; -} - -interface State { - editing: boolean; - imageDialog: boolean; - text: string; - undoStack: UndoStack; - savedText: string; - addingComment: boolean; - allCommentsVisible: boolean; -} - -const styles = { - wrapper: css({ - background: Colors.cardBackground, - padding: "10px", - marginBottom: "20px", - boxShadow: Colors.cardShadow, - "@media (max-width: 699px)": { - padding: "5px", - }, - }), - header: css({ - fontSize: "24px", - marginBottom: "10px", - marginLeft: "-10px", - marginRight: "-10px", - marginTop: "-10px", - padding: "10px", - display: "flex", - justifyContent: "space-between", - alignItems: "center", - background: Colors.cardHeader, - color: Colors.cardHeaderForeground, - "@media (max-width: 699px)": { - fontSize: "20px", - marginLeft: "-5px", - marginRight: "-5px", - marginTop: "-5px", - }, - }), - voteWrapper: css({ - display: "flex", - alignItems: "center", - }), - voteImgWrapper: css({ - cursor: "pointer", - }), - voteImg: css({ - height: "26px", - marginLeft: "11px", - marginRight: "11px", - marginBottom: "-4px", // no idea what's going on... - "@media (max-width: 699px)": { - height: "20px", - marginBottom: "-3px", - }, - }), - expertVoteImg: css({ - height: "26px", - marginLeft: "3px", - marginRight: "11px", - marginBottom: "-4px", // no idea what's going on... - "@media (max-width: 699px)": { - height: "20px", - marginBottom: "-3px", - }, - }), - voteCount: css({ - marginLeft: "9px", - marginRight: "9px", - }), - expertVoteCount: css({ - marginLeft: "9px", - marginRight: "3px", - }), - answer: css({ - marginTop: "15px", - marginLeft: "10px", - marginRight: "10px", - }), - answerInput: css({ - marginLeft: "5px", - marginRight: "5px", - }), - answerTexHint: css({ - display: "flex", - justifyContent: "space-between", - marginBottom: "10px", - marginLeft: "5px", - marginRight: "5px", - color: colors.silentText, - }), - comments: css({ - marginLeft: "25px", - marginTop: "10px", - marginRight: "25px", - }), - textareaInput: css({ - width: "100%", - resize: "vertical", - marginTop: "10px", - marginBottom: "5px", - padding: "5px", - boxSizing: "border-box", - }), - actionButtons: css({ - width: "100%", - display: "flex", - justifyContent: "flex-end", - marginRight: "25px", - }), - actionButton: css({ - cursor: "pointer", - marginLeft: "10px", - }), - actionImg: css({ - height: "26px", - }), - permalink: css({ - marginRight: "5px", - "& a:link, & a:visited": { - color: Colors.silentText, - }, - "& a:hover": { - color: Colors.linkHover, - }, - }), - moreComments: css({ - cursor: "pointer", - color: colors.silentText, - borderTop: "1px solid " + Colors.commentBorder, - paddingTop: "2px", - }), -}; - -export default class AnswerComponent extends React.Component<Props, State> { - state: State = { - editing: this.props.answer.canEdit && this.props.answer.text.length === 0, - imageDialog: false, - savedText: this.props.answer.text, - text: this.props.answer.text, - allCommentsVisible: false, - addingComment: false, - undoStack: { prev: [], next: [] }, - }; - - componentDidUpdate( - prevProps: Readonly<Props>, - prevState: Readonly<State>, - ): void { - if (prevProps.answer.text !== this.props.answer.text) { - this.setState({ - text: this.props.answer.text, - savedText: this.props.answer.text, - }); - } - } - - setMainDivRef = (element: HTMLDivElement) => { - this.props.answer.divRef = element; - }; - - removeAnswer = () => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove answer?"); - if (confirmation) { - fetchPost(`/api/exam/removeanswer/${this.props.answer.oid}/`, {}) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - } - }; - - saveAnswer = () => { - fetchPost(`/api/exam/setanswer/${this.props.sectionId}/`, { - text: this.state.text, - legacy_answer: this.props.answer.isLegacyAnswer, - }) - .then(res => { - this.setState(prevState => ({ - editing: false, - savedText: prevState.text, - })); - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - }; - - cancelEdit = () => { - this.setState(prevState => ({ - editing: false, - text: prevState.savedText, - })); - this.props.onCancelEdit(); - }; - - startEdit = () => { - this.setState({ - editing: true, - }); - }; - - toggleAddingComment = () => { - this.setState(prevState => ({ - addingComment: !prevState.addingComment, - })); - }; - - answerTextareaChange = (newValue: string) => { - this.setState({ - text: newValue, - }); - }; - - toggleAnswerLike = (like: Number) => { - const newLike = - like === 1 - ? this.props.answer.isUpvoted - ? 0 - : 1 - : this.props.answer.isDownvoted - ? 0 - : -1; - fetchPost(`/api/exam/setlike/${this.props.answer.oid}/`, { like: newLike }) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - }; +const AnswerWrapper = styled(Card)` + margin-top: 1em; + margin-bottom: 1em; +`; - toggleAnswerFlag = () => { - fetchPost(`/api/exam/setflagged/${this.props.answer.oid}/`, { - flagged: !this.props.answer.isFlagged, - }) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - }; +const AuthorWrapper = styled.h6` + margin: 0; +`; - resetAnswerFlagged = () => { - fetchPost(`/api/exam/resetflagged/${this.props.answer.oid}/`, {}) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - }; +const AnswerToolbar = styled(ButtonToolbar)` + justify-content: flex-end; + margin: 0 -0.3em; +`; - toggleAnswerExpertVote = () => { - fetchPost(`/api/exam/setexpertvote/${this.props.answer.oid}/`, { - vote: !this.props.answer.isExpertVoted, - }) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); - }; +const bodyCanEditStyle = css` + position: relative; + padding-top: 2.3em !important; +`; - toggleComments = () => { - this.setState(prevState => ({ - allCommentsVisible: !prevState.allCommentsVisible, - })); - }; +interface Props { + section?: AnswerSection; + answer?: Answer; + onSectionChanged?: (newSection: AnswerSection) => void; + onDelete?: () => void; + isLegacyAnswer: boolean; +} +const AnswerComponent: React.FC<Props> = ({ + section, + answer, + onDelete, + onSectionChanged, + isLegacyAnswer, +}) => { + const [setFlaggedLoading, setFlagged] = useSetFlagged(onSectionChanged); + const [resetFlaggedLoading, resetFlagged] = useResetFlaggedVote( + onSectionChanged, + ); + const [setExpertVoteLoading, setExpertVote] = useSetExpertVote( + onSectionChanged, + ); + const removeAnswer = useRemoveAnswer(onSectionChanged); + const [updating, update] = useUpdateAnswer(res => { + setEditing(false); + if (onSectionChanged) onSectionChanged(res); + if (answer === undefined && onDelete) onDelete(); + }); + const { isAdmin, isExpert } = useUser()!; + const [confirm, modals] = useConfirm(); + const [isOpen, setIsOpen] = useState(false); + const toggle = useCallback(() => setIsOpen(old => !old), []); + const [editing, setEditing] = useState(false); - copyPermalink = (answer: Answer) => { - const textarea = document.createElement("textarea"); - textarea.style.position = "fixed"; - textarea.style.top = "0"; - textarea.style.left = "0"; - textarea.style.width = "2em"; - textarea.style.height = "2em"; - textarea.style.padding = "0"; - textarea.style.background = "transparent"; - textarea.value = `${document.location.origin}${document.location.pathname}#${answer.longId}`; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - }; + const [draftText, setDraftText] = useState(""); + const [undoStack, setUndoStack] = useState<UndoStack>({ prev: [], next: [] }); + const startEdit = useCallback(() => { + setDraftText(answer?.text ?? ""); + setEditing(true); + }, [answer]); + const onCancel = useCallback(() => { + setEditing(false); + if (answer === undefined && onDelete) onDelete(); + }, [onDelete, answer]); + const save = useCallback(() => { + if (section) update(section.oid, draftText, false); + }, [section, draftText, update]); + const remove = useCallback(() => { + if (answer) confirm("Remove answer?", () => removeAnswer(answer.oid)); + }, [confirm, removeAnswer, answer]); + const [hasCommentDraft, setHasCommentDraft] = useState(false); - render() { - const { answer } = this.props; - let comments = answer.comments; - const commentLimit = this.props.isReadonly ? 0 : 3; - if (!this.state.allCommentsVisible && comments.length > commentLimit) { - comments = comments.slice(0, commentLimit); - } - return ( - <div {...styles.wrapper}> - <div ref={this.setMainDivRef} {...styles.header}> - <div> - <b {...globalcss.noLinkColor}> - {(answer.authorId.length > 0 && ( - <Link to={`/user/${answer.authorId}`}> - {answer.authorDisplayName} - </Link> - )) || <span>{answer.authorDisplayName}</span>} - </b>{" "} - •{" "} - {moment(answer.time, GlobalConsts.momentParseString).format( - GlobalConsts.momentFormatString, - )} - </div> - {this.props.answer.oid !== undefined && this.props.answer.oid !== "" && ( - <div {...styles.voteWrapper}> - {!this.props.isReadonly && - this.props.isExpert && [ - <div {...styles.expertVoteCount}>{answer.expertvotes}</div>, - <div - {...styles.voteImgWrapper} - onClick={this.toggleAnswerExpertVote} - title="Endorse Answer" - > - <img - {...styles.expertVoteImg} - src={ - "/static/expert" + - (answer.isExpertVoted ? "_active" : "") + - ".svg" - } - alt="Endorse Answer" + const flaggedLoading = setFlaggedLoading || resetFlaggedLoading; + const canEdit = onSectionChanged && (answer?.canEdit || false); + const canRemove = onSectionChanged && (isAdmin || answer?.canEdit || false); + return ( + <> + {modals} + <AnswerWrapper id={answer?.longId}> + <CardHeader> + <Row className="flex-between"> + <Col xs="auto"> + <AuthorWrapper> + {answer?.authorDisplayName ?? + (isLegacyAnswer ? "(Legacy Draft)" : "(Draft)")} + </AuthorWrapper> + </Col> + <Col xs="auto"> + <AnswerToolbar> + {answer && (answer.expertvotes > 0 || setExpertVoteLoading) && ( + <ButtonGroup className="m-1" size="sm"> + <IconButton + tooltip="This answer is endorsed by an expert" + color="primary" + icon="STAR_FILLED" + active /> - </div>, - ]} - {(this.props.isReadonly || !this.props.isExpert) && - answer.expertvotes > 0 && [ - answer.expertvotes > 1 && ( - <div {...styles.expertVoteCount}>{answer.expertvotes}</div> - ), - <div> - <img - {...styles.expertVoteImg} - src="/static/expert_active.svg" - title={ - "Expert Endorsed" + - (answer.expertvotes > 1 - ? " (" + answer.expertvotes + " votes)" - : "") + <SmallButton color="primary" active> + {answer.expertvotes} + </SmallButton> + <IconButton + color="primary" + tooltip={ + answer.isExpertVoted + ? "Remove expert vote" + : "Add expert vote" + } + icon={answer.isExpertVoted ? "CLOSE" : "PLUS"} + onClick={() => + setExpertVote(answer.oid, !answer.isExpertVoted) } - alt="This answer is endorsed by an expert" /> - </div>, - ]} - {!this.props.isReadonly && ( - <div - {...styles.voteImgWrapper} - onClick={() => this.toggleAnswerLike(-1)} - title="Downvote Answer" - > - <img - {...styles.voteImg} - src={ - "/static/downvote" + - (answer.isDownvoted ? "_orange" : "_white") + - ".svg" + </ButtonGroup> + )} + {answer && (answer.flagged > 0 || flaggedLoading) && ( + <ButtonGroup className="m-1" size="sm"> + <IconButton + tooltip="This answer was flagged as inappropriate by a user. A moderator will decide if the answer should be removed." + color="danger" + icon="FLAG" + title="Flagged as Inappropriate" + active + > + Inappropriate + </IconButton> + <SmallButton + color="danger" + tooltip={`${answer.flagged} users consider this answer inappropriate.`} + active + > + {answer.flagged} + </SmallButton> + <IconButton + color="danger" + icon={answer.isFlagged ? "CLOSE" : "PLUS"} + onClick={() => setFlagged(answer.oid, !answer.isFlagged)} + /> + {isAdmin && ( + <IconButton + tooltip="Remove all inappropriate flags" + color="danger" + icon="DELETE" + onClick={() => resetFlagged(answer.oid)} + /> + )} + </ButtonGroup> + )} + {answer && onSectionChanged && ( + <Score + oid={answer.oid} + upvotes={answer.upvotes} + expertUpvotes={answer.expertvotes} + userVote={ + answer.isUpvoted ? 1 : answer.isDownvoted ? -1 : 0 } - alt="Downvote" + onSectionChanged={onSectionChanged} /> - </div> + )} + </AnswerToolbar> + </Col> + </Row> + </CardHeader> + <CardBody className={canRemove ? bodyCanEditStyle : ""}> + <div className="position-absolute position-top-right"> + <ButtonGroup> + {!editing && canEdit && ( + <SmallButton + size="sm" + color="white" + onClick={startEdit} + tooltip="Edit answer" + > + <Icon icon={ICONS.EDIT} size={18} /> + </SmallButton> )} - <div {...styles.voteCount}>{answer.upvotes}</div> - {!this.props.isReadonly && ( - <div - {...styles.voteImgWrapper} - onClick={() => this.toggleAnswerLike(1)} - title="Upvote Answer" + {answer && canRemove && ( + <SmallButton + size="sm" + color="white" + onClick={remove} + tooltip="Delete answer" > - <img - {...styles.voteImg} - src={ - "/static/upvote" + - (answer.isUpvoted ? "_orange" : "_white") + - ".svg" - } - alt="Upvote" - /> - </div> + <Icon icon={ICONS.DELETE} size={18} /> + </SmallButton> )} - </div> - )} - </div> - {!this.state.editing && ( - <div {...styles.answer}> - <MarkdownText value={this.state.text} /> + </ButtonGroup> </div> - )} - {this.state.editing && ( - <div> - <div {...styles.answerInput}> - <Editor - value={this.state.text} - onChange={this.answerTextareaChange} - imageHandler={imageHandler} - preview={str => <MarkdownText value={str} />} - undoStack={this.state.undoStack} - setUndoStack={undoStack => this.setState({ undoStack })} - /> - </div> - <div {...styles.answerTexHint}> - <div {...styles.actionButtons}> - <div {...styles.actionButton} onClick={this.saveAnswer}> - <img - {...styles.actionImg} - src="/static/save.svg" - title="Save" - alt="Save" - /> - </div> - <div {...styles.actionButton} onClick={this.cancelEdit}> - <img - {...styles.actionImg} - src="/static/cancel.svg" - title="Cancel" - alt="Cancel" - /> - </div> - </div> - </div> - </div> - )} - - {!this.state.editing && ( - <div {...styles.actionButtons}> - <div {...styles.permalink}> - <small> - <Link - onClick={() => this.copyPermalink(answer)} - to={"/exams/" + this.props.filename + "#" + answer.longId} - title="Copy link to clipboard" + {editing || answer === undefined ? ( + <Editor + value={draftText} + onChange={setDraftText} + imageHandler={imageHandler} + preview={value => <MarkdownText value={value} />} + undoStack={undoStack} + setUndoStack={setUndoStack} + /> + ) : ( + <MarkdownText value={answer?.text ?? ""} /> + )} + <Row className="flex-between"> + <Col xs="auto"> + {(answer === undefined || editing) && ( + <IconButton + className="m-1" + color="primary" + size="sm" + onClick={save} + loading={updating} + icon="SAVE" > - Permalink - </Link> - </small> - </div> - {!this.props.isReadonly && this.state.savedText.length > 0 && ( - <div {...styles.actionButton} onClick={this.toggleAddingComment}> - <img - {...styles.actionImg} - src="/static/comment.svg" - title="Add Comment" - alt="Add Comment" - /> - </div> - )} - {!this.props.isReadonly && answer.canEdit && ( - <div {...styles.actionButton} onClick={this.startEdit}> - <img - {...styles.actionImg} - src="/static/edit.svg" - title="Edit Answer" - alt="Edit Answer" - /> - </div> - )} - {!this.props.isReadonly && (answer.canEdit || this.props.isAdmin) && ( - <div {...styles.actionButton} onClick={this.removeAnswer}> - <img - {...styles.actionImg} - src="/static/delete.svg" - title="Delete Answer" - alt="Delete Answer" - /> - </div> - )} - {!this.props.isReadonly && ( - <div {...styles.actionButton} onClick={this.toggleAnswerFlag}> - <img - {...styles.actionImg} - src={ - answer.isFlagged - ? "/static/flag_active.svg" - : "/static/flag.svg" - } - title="Flag as Inappropriate" - alt="Flag as Inappropriate" - /> - </div> - )} - {!this.props.isReadonly && answer.flagged > 0 && ( - <div {...styles.actionButton} onClick={this.resetAnswerFlagged}> - {answer.flagged} - </div> - )} - </div> - )} + Save + </IconButton> + )} + </Col> + <Col xs="auto"> + {onSectionChanged && ( + <> + <ButtonGroup className="m-1"> + {(answer === undefined || editing) && ( + <IconButton size="sm" onClick={onCancel} icon="CLOSE"> + {editing ? "Cancel" : "Delete Draft"} + </IconButton> + )} + {answer !== undefined && ( + <IconButton + size="sm" + onClick={() => setHasCommentDraft(true)} + icon="PLUS" + disabled={hasCommentDraft} + > + Add Comment + </IconButton> + )} + {answer !== undefined && ( + <ButtonDropdown isOpen={isOpen} toggle={toggle}> + <DropdownToggle size="sm" caret> + More + </DropdownToggle> + <DropdownMenu> + {answer.expertvotes === 0 && isExpert && ( + <DropdownItem + onClick={() => setFlagged(answer.oid, true)} + > + Endorse Answer + </DropdownItem> + )} + {answer.flagged === 0 && ( + <DropdownItem + onClick={() => setFlagged(answer.oid, true)} + > + Flag as Inappropriate + </DropdownItem> + )} + <DropdownItem + onClick={() => + copy( + `${document.location.origin}${document.location.pathname}#${answer.longId}`, + ) + } + > + Copy Permalink + </DropdownItem> + </DropdownMenu> + </ButtonDropdown> + )} + </ButtonGroup> + </> + )} + </Col> + </Row> - {(answer.comments.length > 0 || this.state.addingComment) && ( - <div {...styles.comments}> - {this.state.addingComment && ( - <Comment - isNewComment={true} - isReadonly={this.props.isReadonly} - isAdmin={this.props.isAdmin} - sectionId={this.props.sectionId} - answerId={answer.oid} - comment={{ - oid: "", - longId: "", - text: "", - authorId: "", - authorDisplayName: "", - canEdit: true, - time: "", - edittime: "", - }} - onSectionChanged={this.props.onSectionChanged} - onNewCommentSaved={this.toggleAddingComment} + {answer && + onSectionChanged && + (hasCommentDraft || answer.comments.length > 0) && ( + <CommentSectionComponent + hasDraft={hasCommentDraft} + answer={answer} + onSectionChanged={onSectionChanged} + onDraftDelete={() => setHasCommentDraft(false)} /> )} - {comments.map(e => ( - <Comment - key={e.oid} - isReadonly={this.props.isReadonly} - isAdmin={this.props.isAdmin} - comment={e} - sectionId={this.props.sectionId} - answerId={answer.oid} - onSectionChanged={this.props.onSectionChanged} - /> - ))} - {comments.length < answer.comments.length && ( - <div {...styles.moreComments} onClick={this.toggleComments}> - Show {answer.comments.length - comments.length} more comments... - </div> - )} - </div> - )} - </div> - ); - } -} + </CardBody> + </AnswerWrapper> + </> + ); +}; + +export default AnswerComponent; diff --git a/frontend/src/components/attachments-editor.tsx b/frontend/src/components/attachments-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ce3a9cace4575ccca9f380dc467aaaed619ba2f0 --- /dev/null +++ b/frontend/src/components/attachments-editor.tsx @@ -0,0 +1,70 @@ +import { + Badge, + Button, + Input, + InputGroup, + InputGroupAddon, + ListGroup, + ListGroupItem, +} from "@vseth/components"; +import React, { useState } from "react"; +import FileInput from "./file-input"; + +export interface EditorAttachment { + displayname: string; + filename: File | string; +} +interface AttachmentsEditorProps { + attachments: EditorAttachment[]; + setAttachments: (newAttachments: EditorAttachment[]) => void; +} +const toKey = (file: File | string) => + file instanceof File ? file.name : file; +const AttachmentsEditor: React.FC<AttachmentsEditorProps> = ({ + attachments, + setAttachments, +}) => { + const [file, setFile] = useState<File | undefined>(); + const [displayName, setDisplayName] = useState(""); + const onAdd = () => { + if (file === undefined) return; + setAttachments([ + ...attachments, + { displayname: displayName, filename: file }, + ]); + setFile(undefined); + setDisplayName(""); + }; + const onRemove = (index: number) => { + setAttachments(attachments.filter((_item, i) => i !== index)); + }; + return ( + <> + <ListGroup> + {attachments.map(({ displayname, filename }, index) => ( + <ListGroupItem key={toKey(filename)}> + <Button close onClick={() => onRemove(index)} /> + {displayname} <Badge>{toKey(filename)}</Badge> + {filename instanceof File && <Badge color="success">New</Badge>} + </ListGroupItem> + ))} + </ListGroup> + <InputGroup> + <FileInput accept="application/pdf" value={file} onChange={setFile} /> + + <Input + type="text" + placeholder="Display name" + value={displayName} + onChange={e => setDisplayName(e.currentTarget.value)} + /> + <InputGroupAddon addonType="append"> + <Button block onClick={onAdd}> + Add + </Button> + </InputGroupAddon> + </InputGroup> + </> + ); +}; +export default AttachmentsEditor; diff --git a/frontend/src/components/attachments.tsx b/frontend/src/components/attachments.tsx deleted file mode 100644 index 011ec087a15d50e2d8e6cbef5d20776934cf1635..0000000000000000000000000000000000000000 --- a/frontend/src/components/attachments.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { Attachment } from "../interfaces"; -import { fetchPost } from "../fetch-utils"; - -const stylesForWidth = { - justWidth: css({ - width: "200px", - }), - inlineBlock: css({ - width: "200px", - margin: "5px", - display: "inline-block", - }), -}; -const styles = { - wrapper: css({}), -}; - -interface Props { - attachments: Attachment[]; - additionalArgs: object; - onAddAttachment: (attachment: Attachment) => void; - onRemoveAttachment: (attachment: Attachment) => void; -} - -interface State { - newFile: Blob; - newDisplayname: string; - error?: string; -} - -export default class Attachments extends React.Component<Props, State> { - state: State = { - newFile: new Blob(), - newDisplayname: "", - }; - - displaynameChanged = (event: React.ChangeEvent<HTMLInputElement>) => { - const newVal = event.target.value; - this.setState({ - newDisplayname: newVal, - }); - }; - - filechange = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - newFile: ev.target.files[0], - }); - } - }; - - uploadFile = () => { - if (!this.state.newDisplayname) { - return; - } - fetchPost("/api/filestore/upload/", { - ...this.props.additionalArgs, - displayname: this.state.newDisplayname, - file: this.state.newFile, - }) - .then(res => { - const att: Attachment = { - displayname: this.state.newDisplayname, - filename: res.filename, - }; - this.props.onAddAttachment(att); - this.setState({ - newDisplayname: "", - newFile: new Blob(), - error: "", - }); - }) - .catch(res => { - this.setState({ - error: res, - }); - }); - }; - - removeFile = (att: Attachment) => { - fetchPost("/api/filestore/remove/" + att.filename + "/", {}).then(res => { - this.props.onRemoveAttachment(att); - }); - }; - - render() { - const atts = this.props.attachments; - return ( - <div {...styles.wrapper}> - {atts.map(att => ( - <div key={att.filename}> - <a - {...stylesForWidth.inlineBlock} - href={"/api/filestore/get/" + att.filename + "/"} - target="_blank" - rel="noopener noreferrer" - > - {att.displayname} - </a> - <button onClick={ev => this.removeFile(att)}>Remove File</button> - </div> - ))} - <div> - <label> - Upload new attachment - <input - type="text" - placeholder="displayname" - title="displayname" - value={this.state.newDisplayname} - onChange={this.displaynameChanged} - /> - </label> - </div> - <div> - <label> - <input type="file" title="attachment" onChange={this.filechange} /> - </label> - <button onClick={this.uploadFile}>Upload</button> - </div> - {this.state.error && <div>{this.state.error}</div>} - </div> - ); - } -} diff --git a/frontend/src/components/autocomplete-input.tsx b/frontend/src/components/autocomplete-input.tsx deleted file mode 100644 index db1c550517cb1994e2d34aeabe37e0a378a2b774..0000000000000000000000000000000000000000 --- a/frontend/src/components/autocomplete-input.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react"; - -interface Props { - name: string; - value: string; - placeholder: string; - autocomplete: string[]; - title?: string; - onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; - onKeyPress?: (event: React.KeyboardEvent<HTMLInputElement>) => void; -} - -export default class AutocompleteInput extends React.Component<Props> { - render() { - return ( - <React.Fragment> - <input - type="text" - list={this.props.name + "_list"} - name={this.props.name} - placeholder={this.props.placeholder} - title={this.props.title} - value={this.props.value} - onChange={this.props.onChange} - autoComplete="off" - onKeyPress={this.props.onKeyPress} - /> - <datalist id={this.props.name + "_list"}> - {this.props.autocomplete.map(entry => ( - <option key={entry} value={entry} /> - ))} - </datalist> - </React.Fragment> - ); - } -} diff --git a/frontend/src/components/button-wrapper-card.tsx b/frontend/src/components/button-wrapper-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c2651c6ed4e2f9bcd69d7bb15b6241c7bb4c0aa --- /dev/null +++ b/frontend/src/components/button-wrapper-card.tsx @@ -0,0 +1,16 @@ +import { CardProps, Card, CardHeader } from "@vseth/components"; +import styled from "@emotion/styled"; +import React from "react"; +const Wrapper = styled(Card)` + margin-top: 1em; + margin-bottom: 1em; +`; +const ButtonWrapperCard: React.FC<CardProps> = ({ children, ...props }) => { + return ( + <Wrapper {...props}> + <CardHeader>{children}</CardHeader> + </Wrapper> + ); +}; + +export default ButtonWrapperCard; diff --git a/frontend/src/components/category-card.tsx b/frontend/src/components/category-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6a1d5dd8bf54ad709204c95f4a1ff14612c3a1f1 --- /dev/null +++ b/frontend/src/components/category-card.tsx @@ -0,0 +1,32 @@ +import { Card, CardBody, CardFooter, Progress } from "@vseth/components"; +import React from "react"; +import { CategoryMetaData } from "../interfaces"; +import { useHistory } from "react-router-dom"; +import styled from "@emotion/styled"; + +const CategoryCardWrapper = styled(Card)` + cursor: pointer; +`; + +const CategoryCard: React.FC<{ category: CategoryMetaData }> = ({ + category, +}) => { + const history = useHistory(); + return ( + <CategoryCardWrapper + onClick={() => history.push(`/category/${category.slug}`)} + > + <CardBody> + <h5>{category.displayname}</h5> + <div> + Exams: {`${category.examcountanswered} / ${category.examcountpublic}`} + </div> + <div>Answers: {((category.answerprogress * 100) | 0).toString()} %</div> + </CardBody> + <CardFooter> + <Progress value={category.answerprogress} max={1} /> + </CardFooter> + </CategoryCardWrapper> + ); +}; +export default CategoryCard; diff --git a/frontend/src/components/category-metadata-editor.tsx b/frontend/src/components/category-metadata-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c49296dd7100b86fd51ee98c66b8b7224cd6089f --- /dev/null +++ b/frontend/src/components/category-metadata-editor.tsx @@ -0,0 +1,351 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Col, + FormGroup, + InputField, + Row, + Select, + TextareaField, +} from "@vseth/components"; +import React from "react"; +import { fetchPost } from "../api/fetch-utils"; +import useForm from "../hooks/useForm"; +import useInitialState from "../hooks/useInitialState"; +import { Attachment, CategoryMetaData } from "../interfaces"; +import { createOptions, options, SelectOption } from "../utils/ts-utils"; +import AttachmentsEditor, { EditorAttachment } from "./attachments-editor"; +import ButtonWrapperCard from "./button-wrapper-card"; +import IconButton from "./icon-button"; +import OfferedInEditor from "./offered-in-editor"; +import UserSetEditor from "./user-set-editor"; + +//'semester', 'form', 'permission', 'remark', 'has_payments', 'more_exams_link' +const setMetaData = async ( + slug: string, + changes: Partial<CategoryMetaData>, +) => { + if (Object.keys(changes).length === 0) return; + await fetchPost(`/api/category/setmetadata/${slug}/`, changes); +}; +const addUserToSet = async (slug: string, key: string, user: string) => { + await fetchPost(`/api/category/addusertoset/${slug}/`, { + key, + user, + }); +}; +const removeUserFromSet = async (slug: string, key: string, user: string) => { + await fetchPost(`/api/category/removeuserfromset/${slug}/`, { + key, + user, + }); +}; +const addMetaCategory = async (slug: string, meta1: string, meta2: string) => { + await fetchPost("/api/category/addmetacategory/", { + meta1, + meta2, + category: slug, + }); +}; +const removeMetaCategory = async ( + slug: string, + meta1: string, + meta2: string, +) => { + await fetchPost("/api/category/removemetacategory/", { + meta1, + meta2, + category: slug, + }); +}; +const addAttachment = async ( + category: string, + displayname: string, + file: File, +) => { + return ( + await fetchPost("/api/filestore/upload/", { + category, + displayname, + file, + }) + ).filename as string; +}; +const removeAttachment = async (filename: string) => { + await fetchPost(`/api/filestore/remove/${filename}/`, {}); +}; + +export interface CategoryMetaDataDraft + extends Omit<CategoryMetaData, "attachments"> { + attachments: EditorAttachment[]; +} + +const applyChanges = async ( + slug: string, + oldMetaData: CategoryMetaData, + newMetaData: CategoryMetaDataDraft, + oldOfferedIn: Array<readonly [string, string]>, + newOfferedIn: Array<readonly [string, string]>, +) => { + const metaDataDiff: Partial<CategoryMetaData> = {}; + if (oldMetaData.semester !== newMetaData.semester) + metaDataDiff.semester = newMetaData.semester; + if (oldMetaData.form !== newMetaData.form) + metaDataDiff.form = newMetaData.form; + if (oldMetaData.remark !== newMetaData.remark) + metaDataDiff.remark = newMetaData.remark; + if (oldMetaData.has_payments !== newMetaData.has_payments) + metaDataDiff.has_payments = newMetaData.has_payments; + if (oldMetaData.more_exams_link !== newMetaData.more_exams_link) + metaDataDiff.more_exams_link = newMetaData.more_exams_link; + if (oldMetaData.permission !== newMetaData.permission) + metaDataDiff.permission = newMetaData.permission; + await setMetaData(slug, metaDataDiff); + const newAttachments: Attachment[] = []; + for (const attachment of newMetaData.attachments) { + if (attachment.filename instanceof File) { + const filename = await addAttachment( + slug, + attachment.displayname, + attachment.filename, + ); + newAttachments.push({ displayname: attachment.displayname, filename }); + } + } + for (const attachment of oldMetaData.attachments) { + if ( + newMetaData.attachments.find( + otherAttachment => otherAttachment.filename === attachment.filename, + ) + ) { + newAttachments.push(attachment); + } else { + await removeAttachment(attachment.filename); + } + } + for (const [newMeta1, newMeta2] of newOfferedIn) { + if ( + oldOfferedIn.find( + ([meta1, meta2]) => meta1 === newMeta1 && meta2 === newMeta2, + ) === undefined + ) { + await addMetaCategory(slug, newMeta1, newMeta2); + } + } + for (const [oldMeta1, oldMeta2] of oldOfferedIn) { + if ( + newOfferedIn.find( + ([meta1, meta2]) => meta1 === oldMeta1 && meta2 === oldMeta2, + ) === undefined + ) { + await removeMetaCategory(slug, oldMeta1, oldMeta2); + } + } + for (const admin of newMetaData.admins) { + if (oldMetaData.admins.indexOf(admin) === -1) { + await addUserToSet(slug, "admins", admin); + } + } + for (const admin of oldMetaData.admins) { + if (newMetaData.admins.indexOf(admin) === -1) { + await removeUserFromSet(slug, "admins", admin); + } + } + + for (const expert of newMetaData.experts) { + if (oldMetaData.experts.indexOf(expert) === -1) { + await addUserToSet(slug, "experts", expert); + } + } + for (const expert of oldMetaData.experts) { + if (newMetaData.experts.indexOf(expert) === -1) { + await removeUserFromSet(slug, "experts", expert); + } + } + return { + ...oldMetaData, + ...metaDataDiff, + attachments: newAttachments, + admins: newMetaData.admins, + experts: newMetaData.experts, + }; +}; + +const semesterOptions = createOptions({ + HS: "HS", + FS: "FS", +}); +const formOptions = createOptions({ + oral: "Oral", + written: "Written", +}); +const permissionOptions = createOptions({ + public: "public", + intern: "intern", + hidden: "hidden", + none: "none", +}); + +interface CategoryMetaDataEditorProps { + currentMetaData: CategoryMetaData; + onMetaDataChange: (newMetaData: CategoryMetaData) => void; + isOpen: boolean; + toggle: () => void; + offeredIn: Array<readonly [string, string]>; +} +const CategoryMetaDataEditor: React.FC<CategoryMetaDataEditorProps> = ({ + onMetaDataChange, + currentMetaData, + isOpen, + toggle, + offeredIn: propOfferedIn, +}) => { + const { error, loading, run: runApplyChanges } = useRequest(applyChanges, { + manual: true, + onSuccess: newMetaData => { + toggle(); + onMetaDataChange(newMetaData); + }, + }); + const [offeredIn, setOfferedIn] = useInitialState< + Array<readonly [string, string]> + >(propOfferedIn); + const { + registerInput, + registerCheckbox, + reset, + formState, + setFormValue, + onSubmit, + } = useForm(currentMetaData as CategoryMetaDataDraft, data => { + runApplyChanges( + currentMetaData.slug, + currentMetaData, + data, + propOfferedIn, + offeredIn, + ); + }); + return ( + <> + <Button close onClick={toggle} /> + <h2>Edit Category</h2> + {error && <Alert color="danger">{error.toString()}</Alert>} + <h6>Meta Data</h6> + <Row form> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Semester</label> + <Select + options={options(semesterOptions)} + value={ + semesterOptions[ + formState.semester as keyof typeof semesterOptions + ] + } + onChange={option => + setFormValue( + "semester", + (option as SelectOption<typeof semesterOptions>).value, + ) + } + /> + </FormGroup> + </Col> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Form</label> + <Select + options={options(formOptions)} + value={formOptions[formState.form as keyof typeof formOptions]} + onChange={option => + setFormValue( + "form", + (option as SelectOption<typeof formOptions>).value, + ) + } + /> + </FormGroup> + </Col> + </Row> + <TextareaField label="Remark" textareaProps={registerInput("remark")} /> + <Row form> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Permission</label> + <Select + options={options(permissionOptions)} + value={ + permissionOptions[ + formState.permission as keyof typeof permissionOptions + ] + } + onChange={option => + setFormValue( + "permission", + (option as SelectOption<typeof permissionOptions>).value, + ) + } + /> + </FormGroup> + </Col> + <Col md={6}> + <InputField + type="url" + label="More Exams Link" + {...registerInput("more_exams_link")} + /> + </Col> + </Row> + <InputField + type="checkbox" + label="Has Payments" + {...registerCheckbox("has_payments")} + /> + <h6>Attachments</h6> + <AttachmentsEditor + attachments={formState.attachments} + setAttachments={a => setFormValue("attachments", a)} + /> + <h6>Offered In</h6> + <OfferedInEditor offeredIn={offeredIn} setOfferedIn={setOfferedIn} /> + <h6>Admins</h6> + <UserSetEditor + users={formState.admins} + setUsers={u => setFormValue("admins", u)} + /> + <h6>Experts</h6> + <UserSetEditor + users={formState.experts} + setUsers={e => setFormValue("experts", e)} + /> + <ButtonWrapperCard> + <Row className="flex-between"> + <Col xs="auto"> + <IconButton + icon="CLOSE" + onClick={() => { + reset(); + toggle(); + }} + > + Cancel + </IconButton> + </Col> + <Col xs="auto"> + <IconButton + icon="SAVE" + color="primary" + loading={loading} + onClick={onSubmit} + > + Save + </IconButton> + </Col> + </Row> + </ButtonWrapperCard> + </> + ); +}; +export default CategoryMetaDataEditor; diff --git a/frontend/src/components/claim-button.tsx b/frontend/src/components/claim-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d3609b2b791a67a181a47e865e63ab7b59298a9d --- /dev/null +++ b/frontend/src/components/claim-button.tsx @@ -0,0 +1,49 @@ +import { CategoryExam } from "../interfaces"; +import { useUser } from "../auth"; +import { hasValidClaim } from "../utils/exam-utils"; +import { Button } from "@vseth/components"; +import React from "react"; +import { fetchPost } from "../api/fetch-utils"; +import { useRequest } from "@umijs/hooks"; + +const setClaim = async (filename: string, claim: boolean) => { + await fetchPost(`/api/exam/claimexam/${filename}/`, { + claim, + }); +}; + +interface Props { + exam: CategoryExam; + reloadExams: () => void; +} +const ClaimButton: React.FC<Props> = ({ exam, reloadExams }) => { + const { username } = useUser()!; + const { loading, run: runSetClaim } = useRequest(setClaim, { + manual: true, + onSuccess: reloadExams, + }); + return !exam.finished_cuts || !exam.finished_wiki_transfer ? ( + hasValidClaim(exam) ? ( + exam.import_claim === username ? ( + <Button + onClick={() => runSetClaim(exam.filename, false)} + disabled={loading} + > + Release Claim + </Button> + ) : ( + <span>Claimed by {exam.import_claim_displayname}</span> + ) + ) : ( + <Button + onClick={() => runSetClaim(exam.filename, true)} + disabled={loading} + > + Claim Exam + </Button> + ) + ) : ( + <span>-</span> + ); +}; +export default ClaimButton; diff --git a/frontend/src/components/comment-section.tsx b/frontend/src/components/comment-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..54c6a87b17f56bffab580589ec9917adabc5a7c2 --- /dev/null +++ b/frontend/src/components/comment-section.tsx @@ -0,0 +1,62 @@ +import { ListGroup } from "@vseth/components"; +import { css } from "emotion"; +import React, { useState } from "react"; +import { Answer, AnswerSection } from "../interfaces"; +import CommentComponent from "./comment"; + +const showMoreStyle = css` + text-decoration: underline; + cursor: pointer; +`; +const listGroupStyle = css` + margin-top: 1em; +`; + +interface Props { + hasDraft: boolean; + answer: Answer; + onSectionChanged: (newSection: AnswerSection) => void; + onDraftDelete: () => void; +} +const CommentSectionComponent: React.FC<Props> = ({ + hasDraft, + answer, + onSectionChanged, + onDraftDelete, +}) => { + const [expanded, setExpanded] = useState(false); + return ( + <> + <ListGroup className={listGroupStyle}> + {(expanded ? answer.comments : answer.comments.slice(0, 3)).map( + comment => ( + <CommentComponent + answer={answer} + onSectionChanged={onSectionChanged} + comment={comment} + key={comment.oid} + /> + ), + )} + {hasDraft && ( + <CommentComponent + answer={answer} + onSectionChanged={onSectionChanged} + comment={undefined} + onDelete={onDraftDelete} + /> + )} + </ListGroup> + {answer.comments.length > 3 && !expanded && ( + <p onClick={() => setExpanded(true)} className={showMoreStyle}> + {answer.comments.length === 4 ? ( + "Show 1 more comment..." + ) : ( + <>Show {answer.comments.length - 3} more comments...</> + )} + </p> + )} + </> + ); +}; +export default CommentSectionComponent; diff --git a/frontend/src/components/comment.tsx b/frontend/src/components/comment.tsx index a9d4b724af1511357ceaaf005f883d6905b924d5..a848a342fd2e3620565dab24899ec21e464c0be4 100644 --- a/frontend/src/components/comment.tsx +++ b/frontend/src/components/comment.tsx @@ -1,224 +1,149 @@ -import * as React from "react"; -import { AnswerSection, Comment } from "../interfaces"; -import moment from "moment"; -import { css } from "glamor"; -import MarkdownText from "./markdown-text"; -import { fetchPost, imageHandler } from "../fetch-utils"; -import { Link } from "react-router-dom"; -import globalcss from "../globalcss"; -import GlobalConsts from "../globalconsts"; -import Colors from "../colors"; +import { + ButtonGroup, + Icon, + ICONS, + ListGroupItem, + Spinner, + Row, + Col, +} from "@vseth/components"; +import React, { useState } from "react"; +import { addNewComment, removeComment, updateComment } from "../api/comment"; +import { imageHandler } from "../api/fetch-utils"; +import { useMutation } from "../api/hooks"; +import { useUser } from "../auth"; +import useConfirm from "../hooks/useConfirm"; +import { Answer, AnswerSection, Comment } from "../interfaces"; import Editor from "./Editor"; import { UndoStack } from "./Editor/utils/undo-stack"; +import IconButton from "./icon-button"; +import MarkdownText from "./markdown-text"; +import SmallButton from "./small-button"; interface Props { - isReadonly: boolean; - isAdmin: boolean; - sectionId: string; - answerId: string; - isNewComment?: boolean; - comment: Comment; - onSectionChanged: (res: { value: AnswerSection }) => void; - onNewCommentSaved?: () => void; + answer: Answer; + comment?: Comment; + onSectionChanged: (newSection: AnswerSection) => void; + onDelete?: () => void; } +const CommentComponent: React.FC<Props> = ({ + answer, + comment, + onSectionChanged, + onDelete, +}) => { + const { isAdmin } = useUser()!; + const [confirm, modals] = useConfirm(); + const [editing, setEditing] = useState(false); + const [draftText, setDraftText] = useState(""); + const [undoStack, setUndoStack] = useState<UndoStack>({ prev: [], next: [] }); + const [addNewLoading, runAddNewComment] = useMutation(addNewComment, res => { + if (onDelete) onDelete(); + onSectionChanged(res); + }); + const [updateLoading, runUpdateComment] = useMutation(updateComment, res => { + setEditing(false); + onSectionChanged(res); + }); + const [removeLoading, runRemoveComment] = useMutation( + removeComment, + onSectionChanged, + ); + const loading = addNewLoading || updateLoading || removeLoading; -interface State { - editing: boolean; - text: string; - savedText: string; - undoStack: UndoStack; -} - -const styles = { - wrapper: css({ - marginBottom: "5px", - borderTop: "1px solid " + Colors.commentBorder, - paddingTop: "5px", - }), - header: css({ - display: "flex", - justifyContent: "space-between", - color: Colors.silentText, - }), - comment: css({ - marginTop: "2px", - marginBottom: "7px", - }), - textareaInput: css({ - width: "100%", - resize: "vertical", - marginTop: "10px", - marginBottom: "5px", - padding: "5px", - boxSizing: "border-box", - }), - actionButtons: css({ - display: "flex", - justifyContent: "flex-end", - }), - actionButton: css({ - cursor: "pointer", - marginLeft: "10px", - }), - actionImg: css({ - height: "26px", - }), -}; - -export default class CommentComponent extends React.Component<Props, State> { - // noinspection PointlessBooleanExpressionJS - state: State = { - editing: !!this.props.isNewComment, - savedText: this.props.comment.text, - text: this.props.comment.text, - undoStack: { prev: [], next: [] }, - }; - - removeComment = () => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove comment?"); - if (confirmation) { - fetchPost(`/api/exam/removecomment/${this.props.comment.oid}/`, {}) - .then(res => { - this.props.onSectionChanged(res); - }) - .catch(() => undefined); + const onSave = () => { + if (comment === undefined) { + runAddNewComment(answer.oid, draftText); + } else { + runUpdateComment(comment.oid, draftText); } }; - - startEdit = () => { - this.setState({ editing: true }); - }; - - cancelEdit = () => { - this.setState(prevState => ({ - editing: false, - text: prevState.savedText, - })); - }; - - saveComment = () => { - if (this.props.isNewComment) { - fetchPost(`/api/exam/addcomment/${this.props.answerId}/`, { - text: this.state.text, - }) - .then(res => { - this.setState({ text: "" }); - if (this.props.onNewCommentSaved) { - this.props.onNewCommentSaved(); - } - this.props.onSectionChanged(res); - }) - .catch(() => undefined); + const onCancel = () => { + if (comment === undefined) { + if (onDelete) onDelete(); } else { - fetchPost(`/api/exam/setcomment/${this.props.comment.oid}/`, { - text: this.state.text, - }) - .then(res => { - this.setState(prevState => ({ - editing: false, - savedText: prevState.text, - })); - this.props.onSectionChanged(res); - }) - .catch(() => undefined); + setEditing(false); } }; - - commentTextareaChange = (newValue: string) => { - this.setState({ - text: newValue, - }); + const startEditing = () => { + if (comment === undefined) return; + setDraftText(comment.text); + setEditing(true); + }; + const remove = () => { + if (comment) + confirm("Remove comment?", () => runRemoveComment(comment.oid)); }; - render() { - const { comment } = this.props; - return ( - <div {...styles.wrapper}> - <div {...styles.header}> - <div> - {this.props.isNewComment && <b>Add comment</b>} - {!this.props.isNewComment && ( - <span> - <b {...globalcss.noLinkColor}> - <Link to={`/user/${comment.authorId}`}> - {comment.authorDisplayName} - </Link> - </b>{" "} - •{" "} - {moment(comment.time, GlobalConsts.momentParseString).format( - GlobalConsts.momentFormatString, - )} - </span> - )} - </div> - <div {...styles.actionButtons}> - {!this.props.isReadonly && comment.canEdit && !this.state.editing && ( - <div {...styles.actionButton} onClick={this.startEdit}> - <img - {...styles.actionImg} - src="/static/edit.svg" - title="Edit Comment" - alt="Edit Comment" - /> - </div> - )} - {!this.props.isReadonly && - (comment.canEdit || this.props.isAdmin) && - !this.state.editing && ( - <div {...styles.actionButton} onClick={this.removeComment}> - <img - {...styles.actionImg} - src="/static/delete.svg" - title="Delete Comment" - alt="Delete Comment" - /> - </div> - )} - </div> - </div> - {!this.state.editing && ( - <div {...styles.comment}> - <MarkdownText - value={this.state.editing ? this.state.text : comment.text} - /> - </div> - )} - {this.state.editing && ( - <div> - <div> - <Editor - value={this.state.text} - onChange={this.commentTextareaChange} - imageHandler={imageHandler} - preview={str => <MarkdownText value={str} />} - undoStack={this.state.undoStack} - setUndoStack={undoStack => this.setState({ undoStack })} - /> - </div> - <div {...styles.actionButtons}> - <div {...styles.actionButton} onClick={this.saveComment}> - <img - {...styles.actionImg} - src="/static/save.svg" - title="Save Comment" - alt="Save Comment" - /> - </div> - {!this.props.isNewComment && ( - <div {...styles.actionButton} onClick={this.cancelEdit}> - <img - {...styles.actionImg} - src="/static/cancel.svg" - title="Cancel" - alt="Cancel" - /> - </div> - )} - </div> - </div> - )} + return ( + <ListGroupItem> + {modals} + <div className="position-absolute position-top-right"> + <ButtonGroup> + {!editing && comment?.canEdit && ( + <SmallButton + tooltip="Edit comment" + size="sm" + color="white" + onClick={startEditing} + > + <Icon icon={ICONS.EDIT} size={18} /> + </SmallButton> + )} + {comment && (comment.canEdit || isAdmin) && ( + <SmallButton + tooltip="Delete comment" + size="sm" + color="white" + onClick={remove} + > + <Icon icon={ICONS.DELETE} size={18} /> + </SmallButton> + )} + </ButtonGroup> </div> - ); - } -} + + <h6>{comment?.authorDisplayName ?? "(Draft)"}</h6> + {comment === undefined || editing ? ( + <> + <Editor + value={draftText} + onChange={setDraftText} + imageHandler={imageHandler} + preview={value => <MarkdownText value={value} />} + undoStack={undoStack} + setUndoStack={setUndoStack} + /> + <Row className="flex-between"> + <Col xs="auto"> + <IconButton + className="m-1" + size="sm" + color="primary" + disabled={loading} + onClick={onSave} + icon="SAVE" + > + {loading ? <Spinner /> : "Save"} + </IconButton> + </Col> + <Col xs="auto"> + <IconButton + className="m-1" + size="sm" + onClick={onCancel} + icon="CLOSE" + > + {comment === undefined ? "Delete Draft" : "Cancel"} + </IconButton> + </Col> + </Row> + </> + ) : ( + <MarkdownText value={comment.text} /> + )} + </ListGroupItem> + ); +}; + +export default CommentComponent; diff --git a/frontend/src/components/exam-category.tsx b/frontend/src/components/exam-category.tsx deleted file mode 100644 index a8ff08fd558a5977209d40b047b69d932aca0e2f..0000000000000000000000000000000000000000 --- a/frontend/src/components/exam-category.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import AutocompleteInput from "./autocomplete-input"; -import { fetchPost } from "../fetch-utils"; -import { Link } from "react-router-dom"; - -interface Props { - exam: string; - category: string; - savedCategory: string; - categories: string[]; - onChange: (exam: string, value: string) => void; - onSave: (exam: string, value: string) => void; -} - -function submitSave( - exam: string, - newCategory: string, - onSave: (exam: string, value: string) => void, -) { - fetchPost(`/api/exam/setmetadata/${exam}/`, { category: newCategory }).then( - () => { - onSave(exam, newCategory); - }, - ); -} - -export default ({ - exam, - category, - savedCategory, - categories, - onChange, - onSave, -}: Props) => ( - <p> - <Link to={"/exams/" + exam}>{exam}</Link>{" "} - <AutocompleteInput - name={exam} - value={category} - placeholder="category..." - autocomplete={categories} - onChange={ev => onChange(exam, ev.target.value)} - /> - <button - onClick={ev => submitSave(exam, category, onSave)} - disabled={savedCategory === category} - > - Save - </button> - </p> -); diff --git a/frontend/src/components/exam-list.tsx b/frontend/src/components/exam-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0878e4eb19bc326d47c3ba97d89bfab025ee906c --- /dev/null +++ b/frontend/src/components/exam-list.tsx @@ -0,0 +1,92 @@ +import { useRequest } from "@umijs/hooks"; +import { Alert, FormGroup, Spinner, Col, Row } from "@vseth/components"; +import React, { useMemo, useState } from "react"; +import { loadList } from "../api/hooks"; +import { useUser } from "../auth"; +import { CategoryMetaData } from "../interfaces"; +import { + dlSelectedExams, + filterMatches, + mapExamsToExamType, +} from "../utils/category-utils"; +import ExamTypeCard from "./exam-type-card"; +import IconButton from "./icon-button"; +import useSet from "../hooks/useSet"; + +interface ExamListProps { + metaData: CategoryMetaData; +} +const ExamList: React.FC<ExamListProps> = ({ metaData }) => { + const { data, loading, error, run: reload } = useRequest( + () => loadList(metaData.slug), + { cacheKey: `exam-list-${metaData.slug}` }, + ); + const [filter, setFilter] = useState(""); + const { isCategoryAdmin } = useUser()!; + const viewableExams = useMemo( + () => + data && + data + .filter(exam => exam.public || isCategoryAdmin) + .filter(exam => filterMatches(filter, exam.displayname)), + [data, isCategoryAdmin, filter], + ); + const examTypeMap = useMemo( + () => (viewableExams ? mapExamsToExamType(viewableExams) : undefined), + [viewableExams], + ); + const [selected, onSelect, onDeselect] = useSet<string>(); + + return ( + <Col lg={6}> + <Row form className="my-2"> + <Col xs="auto"> + <FormGroup className="m-0"> + <IconButton + disabled={selected.size === 0} + onClick={() => dlSelectedExams(selected)} + block + icon="DOWNLOAD" + > + Download selected exams + </IconButton> + </FormGroup> + </Col> + <Col> + <FormGroup className="m-0"> + <div className="search mb-0"> + <input + type="text" + className="search-input" + placeholder="Filter..." + value={filter} + onChange={e => setFilter(e.currentTarget.value)} + /> + <div className="search-icon-wrapper"> + <div className="search-icon" /> + </div> + </div> + </FormGroup> + </Col> + </Row> + {error && <Alert color="danger">{error}</Alert>} + {loading && <Spinner />} + {examTypeMap && + examTypeMap.map( + ([examtype, exams]) => + exams.length > 0 && ( + <ExamTypeCard + examtype={examtype} + exams={exams} + key={examtype} + selected={selected} + onSelect={onSelect} + onDeselect={onDeselect} + reload={reload} + /> + ), + )} + </Col> + ); +}; +export default ExamList; diff --git a/frontend/src/components/exam-metadata-editor.tsx b/frontend/src/components/exam-metadata-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e0d9a9b3bc279b2209960c7ade4add09e5566ab --- /dev/null +++ b/frontend/src/components/exam-metadata-editor.tsx @@ -0,0 +1,418 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Col, + FormGroup, + Input, + Label, + Row, + Select, + TextareaField, + InputField, +} from "@vseth/components"; +import React from "react"; +import { fetchPost } from "../api/fetch-utils"; +import { loadCategories } from "../api/hooks"; +import useInitialState from "../hooks/useInitialState"; +import { Attachment, ExamMetaData } from "../interfaces"; +import { createOptions, options, SelectOption } from "../utils/ts-utils"; +import AttachmentsEditor, { EditorAttachment } from "./attachments-editor"; +import ButtonWrapperCard from "./button-wrapper-card"; +import FileInput from "./file-input"; +import IconButton from "./icon-button"; +import useForm from "../hooks/useForm"; +const stringKeys = [ + "displayname", + "category", + "examtype", + "legacy_solution", + "master_solution", + "resolve_alias", + "remark", +] as const; +const booleanKeys = [ + "public", + "finished_cuts", + "finished_wiki_transfer", + "needs_payment", + "solution_printonly", +] as const; + +const setMetaData = async ( + filename: string, + changes: Partial<ExamMetaData>, +) => { + if (Object.keys(changes).length === 0) return; + await fetchPost(`/api/exam/setmetadata/${filename}/`, changes); +}; +const addAttachment = async (exam: string, displayname: string, file: File) => { + return ( + await fetchPost("/api/filestore/upload/", { + exam, + displayname, + file, + }) + ).filename as string; +}; +const removeAttachment = async (filename: string) => { + await fetchPost(`/api/filestore/remove/${filename}/`, {}); +}; +const setPrintOnly = async (filename: string, file: File) => { + await fetchPost(`/api/exam/upload/printonly/`, { file, filename }); +}; +const removePrintOnly = async (filename: string) => { + await fetchPost(`/api/exam/remove/printonly/${filename}/`, {}); +}; +const setSolution = async (filename: string, file: File) => { + await fetchPost(`/api/exam/upload/solution/`, { file, filename }); +}; +const removeSolution = async (filename: string) => { + await fetchPost(`/api/exam/remove/solution/${filename}/`, {}); +}; + +const examTypeOptions = createOptions({ + Exams: "Exams", + "Old Exams": "Old Exams", +}); +export interface ExamMetaDataDraft extends Omit<ExamMetaData, "attachments"> { + attachments: EditorAttachment[]; +} +const applyChanges = async ( + filename: string, + oldMetaData: ExamMetaData, + newMetaData: ExamMetaDataDraft, + printonly: File | true | undefined, + masterSolution: File | true | undefined, +) => { + const metaDataDiff: Partial<ExamMetaData> = {}; + for (const key of stringKeys) { + if (oldMetaData[key] !== newMetaData[key]) { + metaDataDiff[key] = newMetaData[key]; + } + } + for (const key of booleanKeys) { + if (oldMetaData[key] !== newMetaData[key]) { + metaDataDiff[key] = newMetaData[key]; + } + } + await setMetaData(filename, metaDataDiff); + const newAttachments: Attachment[] = []; + for (const attachment of newMetaData.attachments) { + if (attachment.filename instanceof File) { + const newFilename = await addAttachment( + filename, + attachment.displayname, + attachment.filename, + ); + newAttachments.push({ + displayname: attachment.displayname, + filename: newFilename, + }); + } + } + for (const attachment of oldMetaData.attachments) { + if ( + newMetaData.attachments.find( + otherAttachment => otherAttachment.filename === attachment.filename, + ) + ) { + newAttachments.push(attachment); + } else { + await removeAttachment(attachment.filename); + } + } + if (printonly === undefined && oldMetaData.is_printonly) { + await removePrintOnly(filename); + metaDataDiff.is_printonly = false; + } else if (printonly instanceof File) { + await setPrintOnly(filename, printonly); + metaDataDiff.is_printonly = true; + } + if (masterSolution === undefined && oldMetaData.has_solution) { + await removeSolution(filename); + metaDataDiff.has_solution = false; + } else if (masterSolution instanceof File) { + await setSolution(filename, masterSolution); + metaDataDiff.has_solution = true; + } + return { + ...oldMetaData, + ...metaDataDiff, + attachments: newAttachments, + category_displayname: newMetaData.category_displayname, + }; +}; + +interface Props { + currentMetaData: ExamMetaData; + toggle: () => void; + onMetaDataChange: (newMetaData: ExamMetaData) => void; +} +const ExamMetadataEditor: React.FC<Props> = ({ + currentMetaData, + toggle, + onMetaDataChange, +}) => { + const { loading: categoriesLoading, data: categories } = useRequest( + loadCategories, + ); + const categoryOptions = + categories && + createOptions( + Object.fromEntries( + categories.map( + category => [category.slug, category.displayname] as const, + ), + ) as { [key: string]: string }, + ); + const { loading, error, run: runApplyChanges } = useRequest(applyChanges, { + manual: true, + onSuccess: newMetaData => { + toggle(); + onMetaDataChange(newMetaData); + }, + }); + + const [printonlyFile, setPrintonlyFile] = useInitialState< + File | true | undefined + >(currentMetaData.is_printonly ? true : undefined); + const [masterFile, setMasterFile] = useInitialState<File | true | undefined>( + currentMetaData.has_solution ? true : undefined, + ); + + const { + registerInput, + registerCheckbox, + formState, + setFormValue, + onSubmit, + } = useForm( + currentMetaData as ExamMetaDataDraft, + values => + runApplyChanges( + currentMetaData.filename, + currentMetaData, + values, + printonlyFile, + masterFile, + ), + ["category", "category_displayname", "examtype", "remark", "attachments"], + ); + + return ( + <> + <Button close onClick={toggle} /> + <h2>Edit Exam</h2> + {error && <Alert color="danger">{error.toString()}</Alert>} + <h6>Meta Data</h6> + <Row form> + <Col md={6}> + <InputField + type="text" + label="Display name" + {...registerInput("displayname")} + /> + </Col> + <Col md={6}> + <InputField + type="text" + label="Resolve Alias" + {...registerInput("resolve_alias")} + /> + </Col> + </Row> + <Row form> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Category</label> + <Select + options={categoryOptions ? (options(categoryOptions) as any) : []} + value={categoryOptions && categoryOptions[formState.category]} + onChange={(e: any) => { + setFormValue("category", e.value as string); + setFormValue("category_displayname", e.label as string); + }} + isLoading={categoriesLoading} + required + /> + </FormGroup> + </Col> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Exam type</label> + <Select + options={options(examTypeOptions)} + value={ + examTypeOptions[ + formState.examtype as keyof typeof examTypeOptions + ] + } + onChange={option => + setFormValue( + "examtype", + (option as SelectOption<typeof examTypeOptions>).value, + ) + } + /> + </FormGroup> + </Col> + </Row> + <Row form> + <Col md={6}> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="isPublic" + {...registerCheckbox("public")} + /> + <Label for="isPublic" check> + Public + </Label> + </FormGroup> + </Col> + <Col md={6}> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="needsPayment" + {...registerCheckbox("needs_payment")} + /> + <Label for="needsPayment" check> + Needs Payment + </Label> + </FormGroup> + </Col> + </Row> + <Row form> + <Col md={6}> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="cuts" + {...registerCheckbox("finished_cuts")} + /> + <Label for="cuts" check> + Finished Cuts + </Label> + </FormGroup> + </Col> + <Col md={6}> + <FormGroup check> + <Input + type="checkbox" + label="Finished Wiki Transfer" + name="check" + id="wiki" + {...registerCheckbox("finished_wiki_transfer")} + /> + <Label for="wiki" check> + Finished Wiki Transfer + </Label> + </FormGroup> + </Col> + </Row> + <Row form> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Legacy Solution</label> + <Input type="url" {...registerInput("legacy_solution")} /> + </FormGroup> + </Col> + <Col md={6}> + <FormGroup> + <label className="form-input-label"> + Master Solution <i>(extern)</i> + </label> + <Input type="url" {...registerInput("master_solution")} /> + </FormGroup> + </Col> + </Row> + <Row form> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Print Only File</label> + {printonlyFile === true ? ( + <div className="form-control"> + <a + href={`/api/exam/pdf/solution/${currentMetaData.filename}/`} + target="_blank" + rel="noopener noreferrer" + > + Current File + </a> + <Button close onClick={() => setPrintonlyFile(undefined)} /> + </div> + ) : ( + <FileInput + value={printonlyFile} + onChange={e => setPrintonlyFile(e)} + /> + )} + </FormGroup> + </Col> + <Col md={6}> + <FormGroup> + <label className="form-input-label">Master Solution</label> + {masterFile === true ? ( + <div className="form-control"> + <a + href={`/api/exam/pdf/solution/${currentMetaData.filename}/`} + target="_blank" + rel="noopener noreferrer" + > + Current File + </a> + <Button close onClick={() => setMasterFile(undefined)} /> + </div> + ) : ( + <FileInput value={masterFile} onChange={e => setMasterFile(e)} /> + )} + </FormGroup> + </Col> + </Row> + <Row form> + <Col md={12}> + <FormGroup> + <label className="form-input-label">Remark</label> + <TextareaField + textareaProps={{ + onChange: e => setFormValue("remark", e.currentTarget.value), + }} + > + {formState.remark} + </TextareaField> + </FormGroup> + </Col> + </Row> + <h6>Attachments</h6> + <AttachmentsEditor + attachments={formState.attachments} + setAttachments={a => setFormValue("attachments", a)} + /> + <ButtonWrapperCard> + <Row className="flex-between"> + <Col xs="auto"> + <IconButton icon="CLOSE" onClick={toggle}> + Cancel + </IconButton> + </Col> + <Col xs="auto"> + <IconButton + icon="SAVE" + color="primary" + loading={loading} + onClick={onSubmit} + > + Save + </IconButton> + </Col> + </Row> + </ButtonWrapperCard> + </> + ); +}; +export default ExamMetadataEditor; diff --git a/frontend/src/components/exam-panel.tsx b/frontend/src/components/exam-panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..affeed569c4ddbabb2b3e070a94c81648d34b9da --- /dev/null +++ b/frontend/src/components/exam-panel.tsx @@ -0,0 +1,275 @@ +import { useDebounceFn } from "@umijs/hooks"; +import { + ButtonGroup, + Input, + ModalBody, + ModalFooter, + ModalHeader, + Pagination, + PaginationItem, + PaginationLink, + FormGroup, + Label, +} from "@vseth/components"; +import { css } from "emotion"; +import React, { useCallback, useState } from "react"; +import { Link } from "react-router-dom"; +import { useUser } from "../auth"; +import { EditMode, EditState, ExamMetaData } from "../interfaces"; +import PDF from "../pdf/pdf-renderer"; +import IconButton from "./icon-button"; +import Panel from "./panel"; + +const intMap = <T,>(from: number, to: number, cb: (num: number) => T) => { + const acc: T[] = []; + for (let i = from; i <= to; i++) { + acc.push(cb(i)); + } + return acc; +}; +const paginationStyle = css` + & .pagination { + display: inline-block; + } + & .pagination .page-item { + display: inline-block; + } +`; + +export interface DisplayOptions { + displayHiddenPdfSections: boolean; + displayHiddenAnswerSections: boolean; + displayHideShowButtons: boolean; +} + +interface ExamPanelProps { + isOpen: boolean; + toggle: () => void; + metaData: ExamMetaData; + renderer?: PDF; + visiblePages: Set<number>; + + maxWidth: number; + setMaxWidth: (newWidth: number) => void; + + editState: EditState; + setEditState: (newState: EditState) => void; + + displayOptions: DisplayOptions; + setDisplayOptions: (newOptions: DisplayOptions) => void; +} + +const ExamPanel: React.FC<ExamPanelProps> = ({ + isOpen, + toggle, + metaData, + renderer, + visiblePages, + + maxWidth, + setMaxWidth, + + editState, + setEditState, + + displayOptions, + setDisplayOptions, +}) => { + const user = useUser()!; + const isCatAdmin = user.isCategoryAdmin; + const snap = + editState.mode === EditMode.Add || editState.mode === EditMode.Move + ? editState.snap + : true; + const [widthValue, setWidthValue] = useState(maxWidth); + const { run: changeWidth } = useDebounceFn( + (val: number) => setMaxWidth(val), + 500, + ); + const handler = (e: React.ChangeEvent<HTMLInputElement>) => { + const val = parseInt(e.currentTarget.value); + changeWidth(val); + setWidthValue(val); + }; + const download = useCallback(() => { + window.open(`/api/exam/pdf/exam/${metaData.filename}/?download`, "_blank"); + }, [metaData.filename]); + const reportProblem = useCallback(() => { + const subject = encodeURIComponent("[VIS] Community Solutions: Feedback"); + const body = encodeURIComponent( + `Concerning the exam '${metaData.displayname}' of the course '${metaData.category_displayname}' ...`, + ); + window.location.href = `mailto:communitysolutions@vis.ethz.ch?subject=${subject}&body=${body}`; + }, [metaData]); + const setOption = <T extends keyof DisplayOptions>( + name: T, + value: DisplayOptions[T], + ) => setDisplayOptions({ ...displayOptions, [name]: value }); + const scrollToTop = useCallback(() => { + const c = document.documentElement.scrollTop || document.body.scrollTop; + if (c > 0) { + window.requestAnimationFrame(scrollToTop); + window.scrollTo(0, c - c / 10 - 1); + } + }, []); + + return ( + <Panel isOpen={isOpen} toggle={toggle}> + <ModalHeader> + <Link + className="link-text" + to={`/category/${metaData ? metaData.category : ""}`} + > + {metaData && metaData.category_displayname} + </Link> + <p> + <small>{metaData && metaData.displayname}</small> + </p> + </ModalHeader> + <ModalBody> + <h6>Pages</h6> + <Pagination className={paginationStyle}> + {renderer && + intMap(1, renderer.document.numPages, pageNum => ( + <PaginationItem active key={pageNum}> + {visiblePages.has(pageNum) ? ( + <PaginationLink href={`#page-${pageNum}`} className="border"> + {pageNum} + </PaginationLink> + ) : ( + <PaginationLink href={`#page-${pageNum}`}> + {pageNum} + </PaginationLink> + )} + </PaginationItem> + ))} + </Pagination> + + <h6>Size</h6> + <Input + type="range" + min="500" + max="2000" + value={widthValue} + onChange={handler} + /> + <h6>Actions</h6> + <ButtonGroup> + <IconButton + tooltip="Download this exam as a PDF file" + icon="DOWNLOAD" + onClick={download} + /> + <IconButton + tooltip="Report problem" + icon="MESSAGE" + onClick={reportProblem} + /> + <IconButton + tooltip="Back to the top" + icon="ARROW_UP" + onClick={scrollToTop} + /> + </ButtonGroup> + + {isCatAdmin && ( + <> + <h6>Edit Mode</h6> + <ButtonGroup vertical> + <IconButton + tooltip="Disable editing" + onClick={() => setEditState({ mode: EditMode.None })} + icon="CLOSE" + active={editState.mode === EditMode.None} + > + Readonly + </IconButton> + <IconButton + tooltip="Add new cuts" + onClick={() => + setEditState({ + mode: EditMode.Add, + snap, + }) + } + icon="PLUS" + active={editState.mode === EditMode.Add} + > + Add Cuts + </IconButton> + <IconButton + tooltip="The highlighted cut including its answers will be moved to the new location" + icon="CONNECTION_OBJECT_BOTTOM" + active={editState.mode === EditMode.Move} + disabled={editState.mode !== EditMode.Move} + > + Move Cut + </IconButton> + <IconButton + tooltip="Toggle snapping behavior" + icon="TARGET" + onClick={() => + (editState.mode === EditMode.Add || + editState.mode === EditMode.Move) && + setEditState({ ...editState, snap: !snap }) + } + active={snap} + disabled={ + editState.mode !== EditMode.Add && + editState.mode !== EditMode.Move + } + > + Snap + </IconButton> + </ButtonGroup> + <h6>Display Options</h6> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="displayHiddenPdfSections" + checked={displayOptions.displayHiddenPdfSections} + onChange={e => + setOption("displayHiddenPdfSections", e.target.checked) + } + /> + <Label for="displayHiddenPdfSections" check> + Display hidden PDF sections + </Label> + </FormGroup> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="displayHiddenAnswerSections" + checked={displayOptions.displayHiddenAnswerSections} + onChange={e => + setOption("displayHiddenAnswerSections", e.target.checked) + } + /> + <Label for="displayHiddenAnswerSections" check> + Display hidden answer sections + </Label> + </FormGroup> + <FormGroup check> + <Input + type="checkbox" + name="check" + id="displayHideShowButtons" + checked={displayOptions.displayHideShowButtons} + onChange={e => + setOption("displayHideShowButtons", e.target.checked) + } + /> + <Label for="displayHideShowButtons" check> + Display Hide / Show buttons + </Label> + </FormGroup> + </> + )} + </ModalBody> + <ModalFooter>All answers are licensed as CC BY-NC-SA 4.0.</ModalFooter> + </Panel> + ); +}; +export default ExamPanel; diff --git a/frontend/src/components/exam-type-card.tsx b/frontend/src/components/exam-type-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c662d81a7f5cbdc6a715e4dd9d2c63b5728c8d6d --- /dev/null +++ b/frontend/src/components/exam-type-card.tsx @@ -0,0 +1,217 @@ +import { useRequest } from "@umijs/hooks"; +import { Badge, Card, CardHeader, Table, Row, Col } from "@vseth/components"; +import { css } from "emotion"; +import React from "react"; +import { Link, useHistory } from "react-router-dom"; +import { fetchPost } from "../api/fetch-utils"; +import { useUser } from "../auth"; +import useConfirm from "../hooks/useConfirm"; +import { CategoryExam } from "../interfaces"; +import ClaimButton from "./claim-button"; +import IconButton from "./icon-button"; + +const removeExam = async (filename: string) => { + await fetchPost(`/api/exam/remove/exam/${filename}/`, {}); +}; + +const badgeStyle = css` + margin: 0.15rem; + font-size: 0.85rem !important; +`; +const firstCellStyle = css` + cursor: initial; + width: 1%; + white-space: nowrap; +`; +const overflowScroll = css` + overflow: scroll; +`; +const cursorPointer = css` + cursor: pointer; +`; +interface ExamTypeCardProps { + examtype: string; + exams: CategoryExam[]; + selected: Set<string>; + onSelect: (...filenames: string[]) => void; + onDeselect: (...filenames: string[]) => void; + reload: () => void; +} +const ExamTypeCard: React.FC<ExamTypeCardProps> = ({ + examtype, + exams, + + selected, + onSelect, + onDeselect, + reload, +}) => { + const user = useUser()!; + const catAdmin = user.isCategoryAdmin; + const history = useHistory(); + const allSelected = exams.every(exam => selected.has(exam.filename)); + const someSelected = exams.some(exam => selected.has(exam.filename)); + const checked = someSelected; + const indeterminate = someSelected && !allSelected; + const setChecked = (newValue: boolean) => { + if (newValue) onSelect(...exams.map(exam => exam.filename)); + else onDeselect(...exams.map(exam => exam.filename)); + }; + const [confirm, modals] = useConfirm(); + const { run: runRemoveExam } = useRequest(removeExam, { + manual: true, + onSuccess: reload, + }); + const handleRemoveClick = ( + e: React.MouseEvent<HTMLButtonElement>, + exam: CategoryExam, + ) => { + e.stopPropagation(); + confirm( + `Remove the exam named ${exam.displayname}? This will remove all answers and can not be undone!`, + () => runRemoveExam(exam.filename), + ); + }; + + return ( + <> + {modals} + <Card className="my-1"> + <CardHeader tag="h4">{examtype}</CardHeader> + <div className={overflowScroll}> + <Table> + <thead> + <tr> + <th> + <input + type="checkbox" + checked={checked} + ref={el => el && (el.indeterminate = indeterminate)} + onChange={e => setChecked(e.currentTarget.checked)} + /> + </th> + <th /> + </tr> + </thead> + <tbody> + {exams.map(exam => ( + <tr + key={exam.filename} + className={cursorPointer} + onClick={() => history.push(`/exams/${exam.filename}`)} + > + <td + onClick={e => e.stopPropagation()} + className={firstCellStyle} + > + <input + type="checkbox" + checked={selected.has(exam.filename)} + onChange={e => + e.currentTarget.checked + ? onSelect(exam.filename) + : onDeselect(exam.filename) + } + disabled={!exam.canView} + /> + </td> + <td> + <Row> + <Col> + <h6> + {exam.canView ? ( + <Link to={`/exams/${exam.filename}`}> + {exam.displayname} + </Link> + ) : ( + exam.displayname + )} + </h6> + </Col> + <Col xs="auto"> + {catAdmin && ( + <ClaimButton exam={exam} reloadExams={reload} /> + )} + </Col> + </Row> + {user.isAdmin && ( + <IconButton + close + tooltip="Delete exam" + icon="DELETE" + onClick={e => handleRemoveClick(e, exam)} + /> + )} + <div> + {catAdmin && exam.public ? ( + <Badge className={badgeStyle} color="primary"> + public + </Badge> + ) : ( + <Badge className={badgeStyle} color="primary"> + hidden + </Badge> + )} + {exam.needs_payment && ( + <Badge className={badgeStyle} color="info"> + oral + </Badge> + )} + {exam.finished_cuts ? ( + exam.finished_wiki_transfer ? ( + <Badge className={badgeStyle} color="success"> + All done + </Badge> + ) : ( + <Badge className={badgeStyle} color="info"> + Needs Wiki Import + </Badge> + ) + ) : ( + <Badge className={badgeStyle} color="warning"> + Needs Cuts + </Badge> + )} + + {exam.remark && ( + <Badge className={badgeStyle} color="dark"> + {exam.remark} + </Badge> + )} + {exam.is_printonly && ( + <Badge + color="danger" + className={badgeStyle} + title="This exam can only be printed. We can not provide this exam online." + > + (Print Only) + </Badge> + )} + <Badge + color="secondary" + className={badgeStyle} + title={`There are ${exam.count_cuts} questions, of which ${exam.count_answered} have at least one solution.`} + > + {exam.count_answered} / {exam.count_cuts} + </Badge> + {exam.has_solution && ( + <Badge + title="Has an official solution." + color="success" + > + Solution + </Badge> + )} + </div> + </td> + </tr> + ))} + </tbody> + </Table> + </div> + </Card> + </> + ); +}; + +export default ExamTypeCard; diff --git a/frontend/src/components/exam.tsx b/frontend/src/components/exam.tsx new file mode 100644 index 0000000000000000000000000000000000000000..472bbed4026c0c2cfd7cd1332f0cac75df2a36c8 --- /dev/null +++ b/frontend/src/components/exam.tsx @@ -0,0 +1,243 @@ +import React, { useState, useMemo, useEffect, useCallback } from "react"; +import { + ExamMetaData, + Section, + SectionKind, + EditMode, + EditState, + CutVersions, + PdfSection, +} from "../interfaces"; +import AnswerSectionComponent from "./answer-section"; +import PdfSectionCanvas from "../pdf/pdf-section-canvas"; +import { useRequest } from "@umijs/hooks"; +import { loadCutVersions } from "../api/hooks"; +import useSet from "../hooks/useSet"; +import PDF from "../pdf/pdf-renderer"; +import { fetchGet } from "../api/fetch-utils"; + +interface Props { + metaData: ExamMetaData; + sections: Section[]; + width: number; + editState: EditState; + setEditState: (newEditState: EditState) => void; + reloadCuts: () => void; + renderer: PDF; + onCutNameChange: (oid: string, name: string) => void; + onSectionHiddenChange: ( + section: string | [number, number], + newState: boolean, + ) => void; + onAddCut: (filename: string, page: number, height: number) => void; + onMoveCut: ( + filename: string, + cut: string, + page: number, + height: number, + ) => void; + visibleChangeListener: (section: PdfSection, v: boolean) => void; + displayHiddenPdfSections?: boolean; + displayHiddenAnswerSections?: boolean; + displayHideShowButtons?: boolean; +} +function notUndefined<T>(value: T | undefined): value is T { + return value !== undefined; +} +function compactMap<S, T>(array: T[], fn: (arg: T) => S | undefined) { + return array.map(fn).filter(notUndefined); +} +function objectFromMap<S, T, K extends string | number | symbol>( + array: T[], + fn: (arg: T) => readonly [K, S] | undefined, +) { + return Object.fromEntries(compactMap(array, fn)) as Record<K, S>; +} +function useObjectFromMap<S, T, K extends string | number | symbol>( + array: T[], + fn: (arg: T) => readonly [K, S] | undefined, + deps: any[], +) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => objectFromMap(array, fn), [array, fn, ...deps]); +} + +const Exam: React.FC<Props> = React.memo( + ({ + metaData, + sections, + width, + editState, + setEditState, + reloadCuts, + renderer, + onCutNameChange, + onAddCut, + onSectionHiddenChange, + onMoveCut, + visibleChangeListener, + displayHiddenPdfSections = false, + displayHiddenAnswerSections = false, + displayHideShowButtons = true, + }) => { + const getAddCutHandler = useCallback( + (section: PdfSection) => { + return (height: number) => { + if (editState.mode === EditMode.Add) { + onAddCut(metaData.filename, section.start.page, height); + } else if (editState.mode === EditMode.Move) { + onMoveCut( + metaData.filename, + editState.cut, + section.start.page, + height, + ); + } + }; + }, + [editState, metaData.filename, onAddCut, onMoveCut], + ); + + const [visible, show, hide] = useSet<string>(); + const [cutVersions, setCutVersions] = useState<CutVersions>({}); + useRequest(() => loadCutVersions(metaData.filename), { + pollingInterval: 6_000, + onSuccess: response => { + setCutVersions(oldVersions => ({ ...oldVersions, ...response })); + }, + }); + const snap = + editState.mode === EditMode.Add || editState.mode === EditMode.Move + ? editState.snap + : true; + let pageCounter = 0; + const addCutText = + editState.mode === EditMode.Add + ? "Add Cut" + : editState.mode === EditMode.Move + ? "Move Cut" + : undefined; + const hash = document.location.hash.substr(1); + useEffect(() => { + let cancelled = false; + if (hash.length > 0) { + fetchGet(`/api/exam/answer/${hash}`) + .then(res => { + if (cancelled) return; + const sectionId = res.value.sectionId; + show(sectionId); + }) + .catch(() => {}); + } + return () => { + cancelled = true; + }; + }, [hash, show, sections]); + const onChangeListeners = useObjectFromMap( + sections, + section => { + if (section.kind === SectionKind.Pdf) { + return [ + section.key, + (v: boolean) => visibleChangeListener(section, v), + ]; + } else { + return undefined; + } + }, + [sections, visibleChangeListener], + ); + const addCutHandlers = useObjectFromMap( + sections, + section => { + if (section.kind === SectionKind.Pdf) { + return [section.key, getAddCutHandler(section)]; + } else { + return undefined; + } + }, + [sections, getAddCutHandler], + ); + return ( + <> + {sections.map(section => { + if (section.kind === SectionKind.Answer) { + if (displayHiddenAnswerSections || !section.cutHidden) { + return ( + <AnswerSectionComponent + key={section.oid} + oid={section.oid} + onSectionChange={reloadCuts} + onToggleHidden={() => + visible.has(section.oid) + ? hide(section.oid) + : show(section.oid) + } + cutName={section.name} + onCutNameChange={(newName: string) => + onCutNameChange(section.oid, newName) + } + hidden={!visible.has(section.oid)} + cutVersion={cutVersions[section.oid] || section.cutVersion} + setCutVersion={newVersion => + setCutVersions(oldVersions => ({ + ...oldVersions, + [section.oid]: newVersion, + })) + } + onCancelMove={() => setEditState({ mode: EditMode.None })} + onMove={() => + setEditState({ + mode: EditMode.Move, + cut: section.oid, + snap, + }) + } + isBeingMoved={ + editState.mode === EditMode.Move && + editState.cut === section.oid + } + /> + ); + } else { + return null; + } + } else { + if (displayHiddenPdfSections || !section.hidden) { + return ( + <React.Fragment key={section.key}> + {pageCounter < section.start.page && ++pageCounter && ( + <div id={`page-${pageCounter}`} /> + )} + {renderer && ( + <PdfSectionCanvas + /* PDF cut data */ + oid={section.cutOid} + page={section.start.page} + start={section.start.position} + end={section.end.position} + hidden={section.hidden} + /* Handler */ + onSectionHiddenChange={onSectionHiddenChange} + displayHideShowButtons={displayHideShowButtons} + renderer={renderer} + targetWidth={width} + onVisibleChange={onChangeListeners[section.key]} + /* Add cut */ + snap={snap} + addCutText={addCutText} + onAddCut={addCutHandlers[section.key]} + /> + )} + </React.Fragment> + ); + } else { + return null; + } + } + })} + </> + ); + }, +); +export default Exam; diff --git a/frontend/src/components/exams-navbar.tsx b/frontend/src/components/exams-navbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..69d128acb03efe5535e4c856229f2fd18dd8e53f --- /dev/null +++ b/frontend/src/components/exams-navbar.tsx @@ -0,0 +1,109 @@ +import { + Badge, + ICONS, + NavbarBrand, + VSETHNavbar as Navbar, +} from "@vseth/components"; +import { Item } from "@vseth/components/dist/components/VSETHNav/VSETHNavbar"; +import React from "react"; +import { useLocation } from "react-router-dom"; +import { fetchGet } from "../api/fetch-utils"; +import { useUser } from "../auth"; +import { useRequest } from "@umijs/hooks"; +const loadUnreadCount = async () => { + return (await fetchGet("/api/notification/unreadcount/")).value as number; +}; +const ExamsNavbar: React.FC<{}> = () => { + const location = useLocation(); + const user = useUser(); + const { data: unreadCount } = useRequest(loadUnreadCount, { + pollingInterval: 30_000, + }); + const username = user?.username; + const adminItems: Item[] = [ + { + title: "Upload Exam", + linkProps: { + to: "/uploadpdf", + }, + }, + { + title: "Mod Queue", + linkProps: { + to: "/modqueue", + }, + }, + ]; + return ( + <Navbar + lang={"en"} + secondaryLogo={<NavbarBrand href="/">Community Solutions</NavbarBrand>} + primaryActionItems={[]} + secondaryNavItems={[ + { + title: "Home", + icon: ICONS.HOME, + active: location.pathname === "/", + linkProps: { + to: "/", + }, + }, + { + title: "FAQ", + active: location.pathname === "/faq", + linkProps: { + to: "/faq", + }, + }, + { + title: "Feedback", + active: location.pathname === "/feedback", + linkProps: { + to: "/feedback", + }, + }, + { + title: "Scoreboard", + icon: ICONS.LIST, + active: location.pathname === "/scoreboard", + linkProps: { + to: "/scoreboard", + }, + }, + { + title: "More", + active: false, + + childItems: [ + { + title: "Submit Transcript", + linkProps: { + to: "/submittranscript", + }, + }, + ...(typeof user === "object" && user.isAdmin ? adminItems : []), + ], + }, + { + title: (( + <span> + Account + {unreadCount !== undefined && unreadCount > 0 && ( + <> + {" "} + <Badge className="small">{unreadCount}</Badge> + </> + )} + </span> + ) as unknown) as string, + icon: ICONS.USER, + active: location.pathname === `/user/${username}`, + linkProps: { + to: `/user/${username}`, + }, + }, + ]} + /> + ); +}; +export default ExamsNavbar; diff --git a/frontend/src/components/faq-entry.tsx b/frontend/src/components/faq-entry.tsx index 14683816fb78052cb65869ad283d603b470cdd01..70dab37a3bf085e55c92e6511ef52c1903fc096a 100644 --- a/frontend/src/components/faq-entry.tsx +++ b/frontend/src/components/faq-entry.tsx @@ -1,206 +1,132 @@ -import * as React from "react"; -import { css } from "glamor"; +import { ButtonGroup, Card, CardBody, Input, Col } from "@vseth/components"; +import React, { useCallback, useState } from "react"; +import { imageHandler } from "../api/fetch-utils"; +import { useUser } from "../auth"; +import useConfirm from "../hooks/useConfirm"; import { FAQEntry } from "../interfaces"; -import { fetchDelete, fetchPut, imageHandler } from "../fetch-utils"; -import Colors from "../colors"; import Editor from "./Editor"; -import MarkdownText from "./markdown-text"; import { UndoStack } from "./Editor/utils/undo-stack"; - -const styles = { - wrapper: css({ - marginTop: "10px", - background: Colors.cardBackground, - padding: "10px", - marginBottom: "20px", - boxShadow: Colors.cardShadow, - }), - header: css({ - marginLeft: "-10px", - marginRight: "-10px", - marginTop: "-10px", - padding: "7px", - paddingLeft: "10px", - display: "flex", - justifyContent: "space-between", - alignItems: "center", - }), - buttons: css({ - margin: "0", - }), - question: css({ - fontWeight: "bold", - }), - answer: css({ - marginTop: "-10px", - }), - answerEdit: css({ - paddingRight: "14px", - }), - inputElPar: css({ - flexGrow: 1, - }), - inputEl: css({ - width: "100%", - marginLeft: 0, - marginRight: 0, - }), - answerInputEl: css({ - width: "100%", - padding: "5px", - }), -}; - +import IconButton from "./icon-button"; +import MarkdownText from "./markdown-text"; interface Props { isAdmin?: boolean; entry: FAQEntry; prevEntry?: FAQEntry; nextEntry?: FAQEntry; - entryChanged: () => void; + onUpdate: (changes: Partial<FAQEntry>) => void; + onSwap: (me: FAQEntry, other: FAQEntry) => void; + onRemove: () => void; } - -interface State { - editing: boolean; - newQuestion: string; - newAnswer: string; - undoStack: UndoStack; -} - -export default class FAQEntryComponent extends React.Component<Props, State> { - state: State = { - editing: false, - newQuestion: "", - newAnswer: "", - undoStack: { prev: [], next: [] }, - }; - - remove = () => { - if (window.confirm("Do you really want to remove this entry?")) { - fetchDelete(`/api/faq/${this.props.entry.oid}/`) - .then(() => this.props.entryChanged()) - .catch(() => undefined); - } - }; - - swap = (other: FAQEntry) => { - const me = this.props.entry; - fetchPut(`/api/faq/${me.oid}/`, { - order: other.order, - }) - .then(() => - fetchPut(`/api/faq/${other.oid}/`, { - order: me.order, - }), - ) - .then(() => this.props.entryChanged()) - .catch(() => undefined); - }; - - moveUp = () => { - if (this.props.prevEntry) { - this.swap(this.props.prevEntry); - } - }; - - moveDown = () => { - if (this.props.nextEntry) { - this.swap(this.props.nextEntry); - } - }; - - startEdit = () => { - this.setState({ - editing: true, - newQuestion: this.props.entry.question, - newAnswer: this.props.entry.answer, - undoStack: { prev: [], next: [] }, - }); - }; - - save = () => { - fetchPut(`/api/faq/${this.props.entry.oid}/`, { - question: this.state.newQuestion, - answer: this.state.newAnswer, - order: this.props.entry.order, - }) - .then(() => { - this.setState({ - editing: false, - }); - this.props.entryChanged(); - }) - .catch(() => undefined); +const FAQEntryComponent: React.FC<Props> = ({ + entry, + onUpdate, + prevEntry, + nextEntry, + onSwap, + onRemove, +}) => { + const [editing, setEditing] = useState(false); + const [confirm, modals] = useConfirm(); + const [question, setQuestion] = useState(""); + const [answer, setAnswer] = useState(""); + const [undoStack, setUndoStack] = useState<UndoStack>({ prev: [], next: [] }); + const startEditing = useCallback(() => { + setQuestion(entry.question); + setAnswer(entry.answer); + setUndoStack({ prev: [], next: [] }); + setEditing(true); + }, [entry.question, entry.answer]); + const cancel = useCallback(() => setEditing(false), []); + const save = () => { + onUpdate({ question, answer }); + setEditing(false); }; - - cancel = () => { - this.setState({ - editing: false, - }); - }; - - render_edit() { - return ( - <div {...styles.wrapper}> - <div {...styles.header}> - <div {...styles.inputElPar}> - <input - {...styles.inputEl} + const { isAdmin } = useUser()!; + return ( + <Card className="my-1"> + {modals} + <CardBody> + <h4> + {!editing && ( + <IconButton + tooltip="Edit FAQ entry" + close + icon="EDIT" + onClick={() => startEditing()} + /> + )} + {editing ? ( + <Input type="text" placeholder="Question" - title="Question" - onChange={event => - this.setState({ newQuestion: event.currentTarget.value }) - } - value={this.state.newQuestion} + value={question} + onChange={e => setQuestion(e.target.value)} /> - </div> - <div {...styles.buttons}> - <button onClick={this.save}>Save</button> - <button onClick={this.cancel}>Cancel</button> - </div> - </div> - <div {...styles.answerEdit}> + ) : ( + entry.question + )} + </h4> + {editing ? ( <Editor - value={this.state.newAnswer} - onChange={newValue => this.setState({ newAnswer: newValue })} imageHandler={imageHandler} - preview={str => <MarkdownText value={str} />} - undoStack={this.state.undoStack} - setUndoStack={undoStack => this.setState({ undoStack })} + value={answer} + onChange={setAnswer} + undoStack={undoStack} + setUndoStack={setUndoStack} + preview={value => <MarkdownText value={value} />} /> - </div> - </div> - ); - } - - render() { - if (this.state.editing) { - return this.render_edit(); - } - - const entry = this.props.entry; - - return ( - <div {...styles.wrapper}> - <div {...styles.header}> - <div {...styles.question}>{entry.question}</div> - {this.props.isAdmin && ( - <div {...styles.buttons}> - <button onClick={this.moveUp} disabled={!this.props.prevEntry}> - Up - </button> - <button onClick={this.moveDown} disabled={!this.props.nextEntry}> - Down - </button> - <button onClick={this.startEdit}>Edit</button> - <button onClick={this.remove}>Remove</button> - </div> - )} - </div> - <div {...styles.answer}> + ) : ( <MarkdownText value={entry.answer} /> - </div> - </div> - ); - } -} + )} + {isAdmin && ( + <div className="my-2 d-flex flex-between"> + <Col xs="auto"> + {editing && ( + <IconButton + color="primary" + size="sm" + icon="SAVE" + onClick={save} + > + Save + </IconButton> + )} + </Col> + <Col xs="auto"> + <ButtonGroup size="sm"> + <IconButton + tooltip="Move up" + icon="ARROW_UP" + disabled={prevEntry === undefined} + onClick={() => prevEntry && onSwap(entry, prevEntry)} + /> + <IconButton + tooltip="Move down" + icon="ARROW_DOWN" + disabled={nextEntry === undefined} + onClick={() => nextEntry && onSwap(entry, nextEntry)} + /> + <IconButton + tooltip="Delete FAQ entry" + icon="DELETE" + onClick={() => + confirm( + "Are you sure that you want to remove this?", + onRemove, + ) + } + /> + {editing && ( + <IconButton icon="CLOSE" onClick={cancel}> + Cancel + </IconButton> + )} + </ButtonGroup> + </Col> + </div> + )} + </CardBody> + </Card> + ); +}; +export default FAQEntryComponent; diff --git a/frontend/src/components/feedback-entry.tsx b/frontend/src/components/feedback-entry.tsx index f636810d556def125c58f143f8ea96ba6ad9846b..d1966b213d233b50d3fbc784e490417ea1cf7c13 100644 --- a/frontend/src/components/feedback-entry.tsx +++ b/frontend/src/components/feedback-entry.tsx @@ -1,114 +1,62 @@ -import * as React from "react"; -import { css } from "glamor"; -import { FeedbackEntry } from "../interfaces"; +import { useRequest } from "@umijs/hooks"; +import { + Button, + ButtonGroup, + Card, + CardBody, + CardHeader, +} from "@vseth/components"; import moment from "moment"; -import { fetchPost } from "../fetch-utils"; -import Colors from "../colors"; +import * as React from "react"; +import { fetchPost } from "../api/fetch-utils"; import GlobalConsts from "../globalconsts"; +import { FeedbackEntry } from "../interfaces"; + +const setFlag = async (oid: string, flag: "done" | "read", value: boolean) => { + await fetchPost(`/api/feedback/flags/${oid}/`, { + [flag]: value, + }); +}; +const wrapText = (text: string) => { + const textSplit = text.split("\n"); + return textSplit.map(t => <p key={t}>{t}</p>); +}; interface Props { entry: FeedbackEntry; entryChanged: () => void; } - -const styles = { - wrapper: css({ - marginTop: "10px", - background: Colors.cardBackground, - padding: "10px", - marginBottom: "20px", - boxShadow: Colors.cardShadow, - }), - header: css({ - marginBottom: "10px", - marginLeft: "-10px", - marginRight: "-10px", - marginTop: "-10px", - padding: "7px", - paddingLeft: "10px", - background: Colors.cardHeader, - color: Colors.cardHeaderForeground, - display: "flex", - justifyContent: "space-between", - alignItems: "center", - }), - buttons: css({ - margin: "0", - }), - feedbackText: css({ - //whiteSpace: "pre", - }), - buttonRead: [ - css({ - background: Colors.buttonPrimary, - ":hover": { - background: Colors.buttonPrimaryHover, - }, - }), - css({}), - ], - buttonDone: [ - css({ - background: Colors.buttonPrimary, - ":hover": { - background: Colors.buttonPrimaryHover, - }, - }), - css({}), - ], +const FeedbackEntryComponent: React.FC<Props> = ({ entry, entryChanged }) => { + const { run: runSetFlag } = useRequest( + (flag: "done" | "read", value: boolean) => setFlag(entry.oid, flag, value), + { manual: true, onSuccess: entryChanged }, + ); + return ( + <Card className="my-1"> + <CardHeader> + <h6> + {entry.authorDisplayName} •{" "} + {moment(entry.time, GlobalConsts.momentParseString).format( + GlobalConsts.momentFormatString, + )} + </h6> + <ButtonGroup> + <Button + color={entry.done ? "secondary" : "primary"} + onClick={() => runSetFlag("done", !entry.done)} + > + {entry.done ? "Set Undone" : "Set Done"} + </Button> + <Button + color={entry.read ? "secondary" : "primary"} + onClick={() => runSetFlag("read", !entry.read)} + > + {entry.read ? "Set Unread" : "Set Read"} + </Button> + </ButtonGroup> + </CardHeader> + <CardBody>{wrapText(entry.text)}</CardBody> + </Card> + ); }; - -export default class FeedbackEntryComponent extends React.Component<Props> { - setRead = (value: boolean) => { - fetchPost(`/api/feedback/flags/${this.props.entry.oid}/`, { - read: value, - }) - .then(() => this.props.entryChanged()) - .catch(() => undefined); - }; - - setDone = (value: boolean) => { - fetchPost(`/api/feedback/flags/${this.props.entry.oid}/`, { - done: value, - }) - .then(() => this.props.entryChanged()) - .catch(() => undefined); - }; - - wrapText = (text: string) => { - const textSplit = text.split("\n"); - return textSplit.map(t => <p key={t}>{t}</p>); - }; - - render() { - const entry = this.props.entry; - - return ( - <div {...styles.wrapper}> - <div {...styles.header}> - <div> - {entry.authorDisplayName} •{" "} - {moment(entry.time, GlobalConsts.momentParseString).format( - GlobalConsts.momentFormatString, - )} - </div> - <div {...styles.buttons}> - <button - {...styles.buttonDone[entry.done ? 1 : 0]} - onClick={() => this.setDone(!entry.done)} - > - {entry.done ? "Set Undone" : "Set Done"} - </button> - <button - {...styles.buttonRead[entry.read ? 1 : 0]} - onClick={() => this.setRead(!entry.read)} - > - {entry.read ? "Set Unread" : "Set Read"} - </button> - </div> - </div> - <div {...styles.feedbackText}>{this.wrapText(entry.text)}</div> - </div> - ); - } -} +export default FeedbackEntryComponent; diff --git a/frontend/src/components/file-input.tsx b/frontend/src/components/file-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..42d2f4aea7089e65f5e067f70a2c9397f5b70a91 --- /dev/null +++ b/frontend/src/components/file-input.tsx @@ -0,0 +1,48 @@ +import { Badge, Button } from "@vseth/components"; +import React, { useRef } from "react"; +interface FileInputProps + extends Omit< + React.DetailedHTMLProps< + React.InputHTMLAttributes<HTMLInputElement>, + HTMLInputElement + >, + "value" | "onChange" | "contentEditable" + > { + value?: File; + onChange: (newFile?: File) => void; +} +const FileInput: React.FC<FileInputProps> = ({ value, onChange, ...props }) => { + const fileInputRef = useRef<HTMLInputElement>(null); + return ( + <div className="form-control position-relative"> + {value ? ( + <> + <Button close onClick={() => onChange(undefined)} /> + {value.name} + <Badge>{value.type}</Badge> <Badge>{value.size}</Badge> + </> + ) : ( + <> + + <Button + className="position-absolute position-left" + onClick={() => fileInputRef.current && fileInputRef.current.click()} + > + Choose File + </Button> + <input + accept={props.accept} + hidden + type="file" + onChange={e => { + onChange((e.currentTarget.files || [])[0]); + e.currentTarget.value = ""; + }} + ref={fileInputRef} + /> + </> + )} + </div> + ); +}; +export default FileInput; diff --git a/frontend/src/components/grid.tsx b/frontend/src/components/grid.tsx new file mode 100644 index 0000000000000000000000000000000000000000..08c358bbe6ba333b03f55ae100cc3918e788f403 --- /dev/null +++ b/frontend/src/components/grid.tsx @@ -0,0 +1,13 @@ +import { css } from "emotion"; +import React from "react"; + +const gridStyles = css` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + grid-column-gap: 20px; + grid-row-gap: 20px; +`; +const Grid: React.FC<{}> = ({ children }) => { + return <div className={gridStyles}>{children}</div>; +}; +export default Grid; diff --git a/frontend/src/components/header.tsx b/frontend/src/components/header.tsx deleted file mode 100644 index 2d8028add7cf9b85c23fdbf923b3e4bcfdbc21f8..0000000000000000000000000000000000000000 --- a/frontend/src/components/header.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import * as React from "react"; -import { Link } from "react-router-dom"; -import { css } from "glamor"; -import Colors from "../colors"; -import { fetchGet } from "../fetch-utils"; -import { Menu } from "react-feather"; - -interface Props { - username?: string; - displayName?: string; -} - -interface State { - notificationCount: number; - menuVisibleOnMobile: boolean; -} - -const linkStyle = { - ":link": { - color: Colors.headerForeground, - }, - ":visited": { - color: Colors.headerForeground, - }, -}; -const styles = { - wrapper: css({ - zIndex: "100", - position: "relative", - display: "flex", - justifyContent: "space-between", - color: Colors.headerForeground, - background: Colors.headerBackground, - minHeight: "100px", - overflow: "hidden", - boxShadow: Colors.headerShadow, - marginBottom: "10px", - "@media (max-width: 799px)": { - display: "block", - }, - }), - logotitle: css({ - display: "flex", - alignItems: "center", - "@media (max-width: 799px)": { - marginTop: "20px", - marginBottom: "20px", - }, - }), - logo: css({ - height: "54px", - marginLeft: "30px", - "@media (max-width: 799px)": { - height: "34px", - }, - }), - title: css({ - flexGrow: "1", - marginLeft: "30px", - fontSize: "32px", - fontWeight: "bold", - "& a": linkStyle, - "@media (max-width: 799px)": { - fontSize: "20px", - }, - }), - hamburger: css({ - display: "none", - padding: "1em", - cursor: "pointer", - backgroundColor: "transparent", - border: "none", - color: "white", - "&:hover": { - backgroundColor: "transparent", - border: "none", - }, - "@media (max-width: 799px)": { - display: "inline-block", - }, - "& svg": { - verticalAlign: "-0.3em", - }, - }), - activeMenuWrapper: css({ - "@media (max-width: 799px)": { - display: "block", - }, - }), - inactiveMenuWrapper: css({ - "@media (max-width: 799px)": { - display: "none", - }, - }), - menuWrapper: css({ - display: "flex", - alignItems: "center", - }), - menuitem: css({ - display: "block", - marginRight: "40px", - fontSize: "24px", - "& a": linkStyle, - "@media (max-width: 799px)": { - fontSize: "20px", - textAlign: "center", - padding: "10px", - marginRight: "0", - }, - }), -}; - -export default class Header extends React.Component<Props, State> { - state: State = { - notificationCount: 0, - menuVisibleOnMobile: false, - }; - notificationInterval: number | undefined; - - componentDidMount() { - if (this.props.username !== "") { - this.setupInterval(); - } - } - componentDidUpdate(prevProps: Props, _prevState: State) { - if (prevProps.username === "" && this.props.username !== "") { - this.setupInterval(); - } - if (prevProps.username !== "" && this.props.username === "") { - window.clearInterval(this.notificationInterval); - } - } - setupInterval() { - this.notificationInterval = window.setInterval( - this.checkNotificationCount, - 60000, - ); - this.checkNotificationCount(); - } - - componentWillUnmount() { - clearInterval(this.notificationInterval); - } - - checkNotificationCount = () => { - fetchGet("/api/notification/unreadcount/") - .then(res => { - this.setState({ - notificationCount: res.value, - }); - }) - .catch(() => undefined); - }; - - toggleMenu = () => { - this.setState(prevState => ({ - menuVisibleOnMobile: !prevState.menuVisibleOnMobile, - })); - }; - - linkClicked = () => { - this.setState({ - menuVisibleOnMobile: false, - }); - }; - - render() { - return ( - <div {...styles.wrapper}> - <div {...styles.logotitle}> - <div> - <Link to="/" onClick={this.linkClicked}> - <img - {...styles.logo} - src="https://static.vis.ethz.ch/img/spirale_yellow.svg" - alt="VIS Spiral Logo" - /> - </Link> - </div> - <div {...styles.title}> - <Link to="/">VIS Community Solutions</Link> - </div> - <button {...styles.hamburger} onClick={this.toggleMenu}> - <Menu /> - </button> - </div> - <div - {...styles.menuWrapper} - {...(this.state.menuVisibleOnMobile - ? styles.activeMenuWrapper - : styles.inactiveMenuWrapper)} - > - <div {...styles.menuitem}> - <Link to="/faq" onClick={this.linkClicked}> - FAQ - </Link> - </div> - <div {...styles.menuitem}> - <Link to="/feedback" onClick={this.linkClicked}> - Feedback - </Link> - </div> - <div {...styles.menuitem}> - <Link to="/scoreboard" onClick={this.linkClicked}> - Scoreboard - </Link> - </div> - <div {...styles.menuitem}> - <Link - to={`/user/${this.props.username}`} - onClick={this.linkClicked} - > - {this.props.displayName} - {this.state.notificationCount > 0 - ? " (" + this.state.notificationCount + ")" - : ""} - </Link> - </div> - </div> - </div> - ); - } -} diff --git a/frontend/src/components/icon-button.tsx b/frontend/src/components/icon-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..389ad456419bb92e056f7c6490d97d9e345f2d3a --- /dev/null +++ b/frontend/src/components/icon-button.tsx @@ -0,0 +1,58 @@ +import { Button, Icon, ICONS, Spinner, ButtonProps } from "@vseth/components"; +import { css } from "emotion"; +import React from "react"; +import TooltipButton from "./TooltipButton"; +const childStyle = css` + padding-left: 0.8em; +`; +const buttonStyle = css` + min-width: 0; + height: 100%; + display: initial; +`; +interface IconButtonProps extends ButtonProps { + icon: keyof typeof ICONS; + loading?: boolean; + tooltip?: React.ReactNode; +} +const IconButton: React.FC<IconButtonProps> = ({ + size, + loading, + icon, + disabled, + children, + tooltip, + ...props +}) => { + return tooltip ? ( + <TooltipButton + tooltip={tooltip} + {...props} + disabled={disabled || loading} + className={buttonStyle} + size={size} + > + {loading ? ( + <Spinner size={size} /> + ) : ( + <Icon icon={ICONS[icon]} size={size === "lg" ? 20 : 18} /> + )} + {children && <span className={childStyle}>{children}</span>} + </TooltipButton> + ) : ( + <Button + {...props} + disabled={disabled || loading} + className={buttonStyle} + size={size} + > + {loading ? ( + <Spinner size={size} /> + ) : ( + <Icon icon={ICONS[icon]} size={size === "lg" ? 20 : 18} /> + )} + {children && <span className={childStyle}>{children}</span>} + </Button> + ); +}; +export default IconButton; diff --git a/frontend/src/components/image-overlay.tsx b/frontend/src/components/image-overlay.tsx index 111a1369211f83ae50910ddfea7a4841fa1c9ffa..4441f553385cb5bc75bd2e9ce5adbdecfaf48bcd 100644 --- a/frontend/src/components/image-overlay.tsx +++ b/frontend/src/components/image-overlay.tsx @@ -1,242 +1,117 @@ -import * as React from "react"; -import { css } from "glamor"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import { RefObject } from "react"; -import Colors from "../colors"; - -const styles = { - background: css({ - background: "rgba(0, 0, 0, 0.2)", - position: "fixed", - top: "0", - left: "0", - right: "0", - bottom: "0", - paddingTop: "200px", - paddingBottom: "200px", - zIndex: 100, - "@media (max-height: 799px)": { - paddingTop: "50px", - paddingBottom: "50px", - }, - }), - dialog: css({ - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - width: "70%", - maxWidth: "1200px", - height: "100%", - margin: "auto", - "@media (max-width: 699px)": { - width: "90%", - }, - }), - header: css({ - background: Colors.cardHeader, - display: "flex", - justifyContent: "space-between", - alignItems: "center", - paddingRight: "20px", - }), - title: css({ - color: Colors.cardHeaderForeground, - fontSize: "20px", - padding: "20px", - }), - content: css({ - padding: "20px", - overflow: "auto", - height: "calc(100% - 110px)", - }), - uploadForm: css({ - textAlign: "center", - }), - images: css({ - display: "flex", - flexWrap: "wrap", - height: "100%", - marginTop: "20px", - }), - imageWrapper: css({ - width: "138px", - height: "138px", - }), - imageSelected: css({ - background: Colors.activeImage, - }), - imageSmallWrapper: css({ - width: "128px", - height: "128px", - margin: "5px", - }), - imageSmall: css({ - maxWidth: "128px", - maxHeight: "128px", - }), - deleteImgWrapper: css({ - position: "relative", - top: "-133px", - left: "5px", - height: "32px", - width: "32px", - }), - deleteImg: css({ - height: "32px", - width: "32px", - cursor: "pointer", - }), -}; - -interface Props { - onClose: (image: string) => void; -} - -interface State { - images: string[]; - file?: Blob; - selected: string; - error?: string; +import { + Button, + Card, + CardColumns, + CardImg, + Modal, + ModalBody, + ModalHeader, + Row, + Col, +} from "@vseth/components"; +import React, { useEffect, useState } from "react"; +import { useImages } from "../api/image"; +import useSet from "../hooks/useSet"; +import FileInput from "./file-input"; +import { css } from "emotion"; +const columnStyle = css` + column-gap: 0; + grid-column-gap: 0; + margin: 0 -0.75em; + padding-top: 1em; +`; +const cardWrapperStyle = css` + padding: 0 0.75em; +`; +interface ModalProps { + isOpen: boolean; + toggle: () => void; + closeWithImage: (image: string) => void; } - -export default class ImageOverlay extends React.Component<Props, State> { - state: State = { - images: [], - selected: "", - }; - - fileInputRef: RefObject<HTMLInputElement> = React.createRef(); - - componentDidMount() { - this.loadImages(); - } - - loadImages = () => { - fetchGet("/api/image/list/") - .then(res => { - res.value.reverse(); - this.setState({ images: res.value }); - }) - .catch(e => { - this.setState({ error: e.toString() }); - }); - }; - - cancelDialog = () => { - this.props.onClose(""); - }; - - chooseImage = () => { - this.props.onClose(this.state.selected); - }; - - uploadImage = (ev: React.FormEvent<HTMLFormElement>) => { - ev.preventDefault(); - - if (!this.state.file) { - return; - } - - fetchPost("/api/image/upload/", { - file: this.state.file, - }) - .then(res => { - this.setState({ - selected: res.filename, - file: undefined, - }); - if (this.fileInputRef.current) { - this.fileInputRef.current.value = ""; - } - this.loadImages(); - }) - .catch(() => undefined); - }; - - handleFileChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - file: ev.target.files[0], - }); +const ImageModal: React.FC<ModalProps> = ({ + isOpen, + toggle, + closeWithImage, +}) => { + const { images, add, remove, reload } = useImages(); + const [selected, select, unselect, setSelected] = useSet<string>(); + useEffect(() => setSelected(), [images, setSelected]); + const [file, setFile] = useState<File | undefined>(undefined); + const removeSelected = () => { + for (const image of selected) { + remove(image); } }; + return ( + <Modal size="lg" isOpen={isOpen} toggle={toggle}> + <ModalHeader>Images</ModalHeader> + <ModalBody> + <Row> + <Col> + <FileInput value={file} onChange={setFile} accept="image/*" /> + </Col> + <Col xs="auto"> + <Button + onClick={() => file && add(file) && setFile(undefined)} + disabled={file === undefined} + > + Upload + </Button> + </Col> + </Row> + + <div className="text-right"> + <Button className="mt-1" onClick={reload}> + Reload + </Button> + <Button + className="mt-1" + color="danger" + disabled={selected.size === 0} + onClick={removeSelected} + > + Delete selected + </Button> + </div> - onImageClick = (image: string) => { - this.setState({ - selected: image, - }); - }; - - removeImage = (image: string) => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove image?"); - if (confirmation) { - fetchPost(`/api/image/remove/${image}/`, {}) - .then(() => { - this.loadImages(); - }) - .catch(() => undefined); - } - }; - - render() { - return ( - <div {...styles.background}> - <div {...styles.dialog}> - <div {...styles.header}> - <div {...styles.title}>Images</div> - <div> - <button onClick={this.chooseImage}>Add</button>{" "} - <button onClick={this.cancelDialog}>Cancel</button> - </div> - </div> - <div {...styles.content}> - <div> - <form {...styles.uploadForm} onSubmit={this.uploadImage}> - <input - onChange={this.handleFileChange} - type="file" - accept="image/*" - ref={this.fileInputRef} - /> - <button type="submit">Upload</button> - </form> - </div> - {this.state.error && <div>{this.state.error}</div>} - <div {...styles.images}> - {this.state.images.map(img => ( - <div - key={img} - onClick={() => this.onImageClick(img)} - {...styles.imageWrapper} - {...(img === this.state.selected - ? styles.imageSelected - : undefined)} + <CardColumns className={columnStyle}> + {images && + images.map(image => ( + <div key={image} className={cardWrapperStyle}> + <Card + className="p-2" + color={selected.has(image) ? "primary" : undefined} + onClick={e => + e.metaKey + ? selected.has(image) + ? unselect(image) + : select(image) + : selected.has(image) + ? setSelected() + : setSelected(image) + } > - <div {...styles.imageSmallWrapper}> - <img - {...styles.imageSmall} - key={img} - src={"/api/image/get/" + img + "/"} - alt="Image Preview" - /> - </div> - <div - {...styles.deleteImgWrapper} - onClick={() => this.removeImage(img)} - > - <img - {...styles.deleteImg} - src={"/static/delete.svg"} - title="Delete" - alt="Delete" - /> - </div> - </div> - ))} - </div> - </div> - </div> - </div> - ); - } -} + <CardImg + width="100%" + src={`/api/image/get/${image}/`} + alt={image} + /> + {selected.has(image) && selected.size === 1 && ( + <div className="position-absolute position-bottom-right"> + <Button + color="primary" + onClick={() => closeWithImage(image)} + > + Insert + </Button> + </div> + )} + </Card> + </div> + ))} + </CardColumns> + </ModalBody> + </Modal> + ); +}; +export default ImageModal; diff --git a/frontend/src/components/loading-overlay.tsx b/frontend/src/components/loading-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df449dd0eaf782f4ac413622d719fa36ad3aa316 --- /dev/null +++ b/frontend/src/components/loading-overlay.tsx @@ -0,0 +1,31 @@ +import { Spinner } from "@vseth/components"; +import { css } from "emotion"; +import React from "react"; +import GlobalConsts from "../globalconsts"; + +const style = css` + background-color: rgba(0, 0, 0, 0.1); + z-index: ${GlobalConsts.zIndex.imageOverlay}; + transition: background-color 1s; +`; +const inactiveStyle = css` + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; + overflow: hidden; + background-color: rgba(0, 0, 0, 0); + z-index: ${GlobalConsts.zIndex.imageOverlay}; + transition: background-color 1s; +`; +const LoadingOverlay: React.FC<{ loading: boolean }> = ({ loading }) => { + return ( + <div className={loading ? style : inactiveStyle}> + <div className="position-center"> + <Spinner /> + </div> + </div> + ); +}; +export default LoadingOverlay; diff --git a/frontend/src/components/login-card.tsx b/frontend/src/components/login-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f3aa5f80b094b2db92588a138bda0a647b078d0f --- /dev/null +++ b/frontend/src/components/login-card.tsx @@ -0,0 +1,114 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Card, + CardBody, + CardHeader, + Col, + Form, + FormGroup, + InputField, + Row, + Select, +} from "@vseth/components"; +import React, { useState } from "react"; +import { useSetUser } from "../auth"; +import { fetchPost } from "../api/fetch-utils"; + +const login = async ( + username: string, + password: string, + simulate_nonadmin: boolean, +) => { + await fetchPost("/api/auth/login/", { + username, + password, + simulate_nonadmin: simulate_nonadmin ? "true" : "false", + }); +}; + +const debugLogins = { + schneij: "UOmtnC7{'%G", + fletchz: "123456abc", + morica: "admin666", +}; +const options = Object.keys(debugLogins).map(username => ({ + value: username, + label: username, +})); + +const LoginCard: React.FC<{}> = () => { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState<string | undefined>(); + const setUser = useSetUser(); + const { loading, run: tryLogin } = useRequest(login, { + manual: true, + onSuccess: e => { + setError(undefined); + setUser(undefined); + }, + onError: e => setError(e.toString()), + }); + return ( + <Card> + <CardHeader>Login</CardHeader> + <CardBody> + <Form + onSubmit={e => { + e.preventDefault(); + tryLogin(username, password, false); + }} + > + {error && <Alert color="danger">{error}</Alert>} + <InputField + type="text" + label="Username" + id="username-field" + placeholder="Username" + value={username} + onChange={e => setUsername(e.currentTarget.value)} + disabled={loading} + required + /> + <InputField + label="Password" + type="password" + id="password-field" + placeholder="Password" + value={password} + onChange={e => setPassword(e.currentTarget.value)} + disabled={loading} + required + /> + <Row form> + <Col md={4}> + <FormGroup> + <Button color="primary" type="submit" disabled={loading}> + Submit + </Button> + </FormGroup> + </Col> + {process.env.NODE_ENV === "development" && ( + <Col md={8}> + <FormGroup> + <Select + options={options} + onChange={({ value }: any) => { + setUsername(value); + setPassword( + debugLogins[value as keyof typeof debugLogins], + ); + }} + /> + </FormGroup> + </Col> + )} + </Row> + </Form> + </CardBody> + </Card> + ); +}; +export default LoginCard; diff --git a/frontend/src/components/loginform.tsx b/frontend/src/components/loginform.tsx deleted file mode 100644 index 50d4ab703b57dbfaad9940235364bc61db5151dc..0000000000000000000000000000000000000000 --- a/frontend/src/components/loginform.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { fetchPost } from "../fetch-utils"; - -interface Props { - userinfoChanged: () => void; -} - -interface State { - username: string; - password: string; - error: string; -} - -const styles = { - wrapper: css({ - maxWidth: "300px", - margin: "auto", - }), - form: css({ - marginTop: "100px", - "& input": { - width: "100%", - }, - "& button": { - width: "100%", - }, - }), -}; - -export default class LoginForm extends React.Component<Props> { - state: State = { - username: "", - password: "", - error: "", - }; - - loginUser = (ev: React.MouseEvent<HTMLElement>) => { - ev.preventDefault(); - - const data = { - username: this.state.username, - password: this.state.password, - simulate_nonadmin: ev.shiftKey ? "true" : "false", - }; - - fetchPost("/api/auth/login/", data) - .then(() => { - this.props.userinfoChanged(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - render() { - return ( - <div {...styles.wrapper}> - {this.state.error && <div>{this.state.error}</div>} - <form {...styles.form}> - <div> - <input - onChange={ev => this.setState({ username: ev.target.value })} - value={this.state.username} - type="text" - placeholder="username" - autoFocus={true} - required - /> - </div> - <div> - <input - onChange={ev => this.setState({ password: ev.target.value })} - value={this.state.password} - type="password" - placeholder="password" - required - /> - </div> - <div> - <button type="submit" onClick={this.loginUser}> - Login - </button> - </div> - </form> - {window.location.hostname === "localhost" && ( - <div> - <p> - <b>Possible Debug Logins</b> - </p> - <p>schneij : UOmtnC7{"{"}'%G</p> - <p>fletchz : 123456abc</p> - <p>morica : admin666</p> - </div> - )} - </div> - ); - } -} diff --git a/frontend/src/components/markdown-text.tsx b/frontend/src/components/markdown-text.tsx index b52c6e3f6582c32db98f69c5c06ec8ef40e4e6df..32746bae61ea5019648266b13be167d64d0e97cf 100644 --- a/frontend/src/components/markdown-text.tsx +++ b/frontend/src/components/markdown-text.tsx @@ -1,40 +1,37 @@ import * as React from "react"; -import { css } from "glamor"; +import { css } from "emotion"; import ReactMarkdown from "react-markdown"; import * as RemarkMathPlugin from "remark-math"; import "katex/dist/katex.min.css"; import TeX from "@matejmazur/react-katex"; -import Colors from "../colors"; import SyntaxHighlighter from "react-syntax-highlighter"; import { solarizedLight } from "react-syntax-highlighter/dist/styles/hljs"; -// import MathJax from 'react-mathjax2'; interface Props { value: string; - background?: string; } -const styles = { - wrapper: css({ - "& p:first-child": { - marginBlockStart: "0", - }, - "& p:last-child": { - marginBlockEnd: "0", - }, - "& img": { - maxWidth: "100%", - }, - "@media (max-width: 699px)": { - "& p": { - marginBlockStart: "0.5em", - marginBlockEnd: "0.5em", - }, - }, - }), -}; +const wrapperStyle = css` + overflow-x: auto; + overflow-y: hidden; + & p:first-child { + margin-block-start: 0; + } + & p:last-child { + margin-block-end: 0; + } + & img { + max-width: 100%; + } + @media (max-width: 699px) { + & p { + margin-block-start: 0.5em; + margin-block-end: 0.5em; + } + } +`; -export default ({ value, background }: Props) => { +export default ({ value }: Props) => { if (value.length === 0) { return <div />; } @@ -48,18 +45,14 @@ export default ({ value, background }: Props) => { ), }; return ( - <div - {...styles.wrapper} - {...css({ background: background || Colors.markdownBackground })} - {...css({ overflow: "auto" })} - > + <div className={wrapperStyle}> <ReactMarkdown source={value} transformImageUri={uri => { if (uri.includes("/")) { return uri; } else { - return "/api/image/get/" + uri + "/"; + return `/api/image/get/${uri}/`; } }} plugins={[RemarkMathPlugin]} diff --git a/frontend/src/components/metadata.tsx b/frontend/src/components/metadata.tsx deleted file mode 100644 index 7b1c20cbdb645a89aa6759e0d73ac4b90e812186..0000000000000000000000000000000000000000 --- a/frontend/src/components/metadata.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { - Attachment, - CategoryMetaDataMinimal, - ExamMetaData, -} from "../interfaces"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import Colors from "../colors"; -import AutocompleteInput from "../components/autocomplete-input"; -import Attachments from "./attachments"; -import { KeysWhereValue } from "../ts-utils"; - -const stylesForWidth = { - justWidth: css({ - width: "200px", - }), - inlineBlock: css({ - width: "200px", - margin: "5px", - display: "inline-block", - }), -}; -const styles = { - wrapper: css({ - width: "430px", - margin: "auto", - marginBottom: "20px", - padding: "10px", - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - "& input[type=text]": stylesForWidth.justWidth, - "& input[type=file]": stylesForWidth.justWidth, - "& label": stylesForWidth.inlineBlock, - "& button": stylesForWidth.justWidth, - }), - title: css({ - paddingLeft: "5px", - }), -}; - -interface Props { - filename?: string; - savedMetaData: ExamMetaData; - onChange: (newMetaData: ExamMetaData) => void; - onFinishEdit: () => void; -} - -interface State { - currentMetaData: ExamMetaData; - categories: CategoryMetaDataMinimal[]; - examTypes: string[]; - printonlyFile: Blob; - solutionFile: Blob; - error?: string; -} - -export default class MetaData extends React.Component<Props, State> { - state: State = { - currentMetaData: { ...this.props.savedMetaData }, - categories: [], - examTypes: [], - printonlyFile: new Blob(), - solutionFile: new Blob(), - }; - - componentDidMount() { - fetchGet("/api/category/listonlyadmin/") - .then(res => { - this.setState({ - categories: res.value, - }); - }) - .catch(() => undefined); - fetchGet("/api/exam/listexamtypes/") - .then(res => { - this.setState({ - examTypes: res.value, - }); - }) - .catch(() => undefined); - } - - saveEdit = () => { - const metadata = { ...this.state.currentMetaData }; - metadata.has_solution = this.props.savedMetaData.has_solution; - metadata.is_printonly = this.props.savedMetaData.is_printonly; - fetchPost(`/api/exam/setmetadata/${this.props.filename}/`, metadata) - .then(() => { - this.props.onChange(metadata); - this.props.onFinishEdit(); - }) - .catch(err => - this.setState({ - error: err.toString(), - }), - ); - }; - - cancelEdit = () => { - this.setState({ - currentMetaData: { ...this.props.savedMetaData }, - }); - this.props.onFinishEdit(); - }; - - valueChanged = ( - key: KeysWhereValue<ExamMetaData, string>, - event: React.ChangeEvent<HTMLInputElement>, - ) => { - const newVal = event.target.value; - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - [key]: newVal, - }, - })); - }; - - checkboxValueChanged = ( - key: KeysWhereValue<ExamMetaData, boolean>, - event: React.ChangeEvent<HTMLInputElement>, - ) => { - const newVal = event.target.checked; - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - [key]: newVal, - }, - })); - }; - - handleFileChangePrintonly = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - printonlyFile: ev.target.files[0], - }); - } - }; - - handleFileChangeSolution = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - solutionFile: ev.target.files[0], - }); - } - }; - - uploadFilePrintonly = () => { - fetchPost("/api/exam/upload/printonly/", { - file: this.state.printonlyFile, - filename: this.state.currentMetaData.filename, - }) - .then(() => { - const newMeta = { ...this.props.savedMetaData }; - newMeta.is_printonly = true; - this.props.onChange(newMeta); - }) - .catch(err => - this.setState({ - error: err.toString(), - }), - ); - }; - - removeFilePrintonly = () => { - fetchPost( - "/api/exam/remove/printonly/" + this.state.currentMetaData.filename + "/", - {}, - ) - .then(() => { - const newMeta = { ...this.props.savedMetaData }; - newMeta.is_printonly = false; - this.props.onChange(newMeta); - }) - .catch(err => - this.setState({ - error: err.toString(), - }), - ); - }; - - uploadFileSolution = () => { - fetchPost("/api/exam/upload/solution/", { - file: this.state.solutionFile, - filename: this.state.currentMetaData.filename, - }) - .then(() => { - const newMeta = { ...this.props.savedMetaData }; - newMeta.has_solution = true; - this.props.onChange(newMeta); - }) - .catch(err => - this.setState({ - error: err.toString(), - }), - ); - }; - - removeFileSolution = () => { - fetchPost( - "/api/exam/remove/solution/" + this.state.currentMetaData.filename + "/", - {}, - ) - .then(() => { - const newMeta = { ...this.props.savedMetaData }; - newMeta.has_solution = false; - this.props.onChange(newMeta); - }) - .catch(err => - this.setState({ - error: err.toString(), - }), - ); - }; - - addAttachment = (att: Attachment) => { - const metadata = { ...this.props.savedMetaData }; - metadata.attachments.push(att); - this.props.onChange(metadata); - }; - - removeAttachment = (att: Attachment) => { - const metadata = { ...this.props.savedMetaData }; - metadata.attachments = metadata.attachments.filter(a => a !== att); - this.props.onChange(metadata); - }; - - render() { - return ( - <div {...styles.wrapper}> - <div> - <input - type="text" - placeholder="display name" - title="display name" - value={this.state.currentMetaData.displayname} - onChange={ev => this.valueChanged("displayname", ev)} - /> - <input - type="text" - placeholder="resolve alias" - title="resolve alias" - value={this.state.currentMetaData.resolve_alias} - onChange={ev => this.valueChanged("resolve_alias", ev)} - /> - </div> - <div> - <AutocompleteInput - value={this.state.currentMetaData.category} - onChange={ev => this.valueChanged("category", ev)} - placeholder="category" - title="category" - autocomplete={this.state.categories.map(cat => cat.displayname)} - name="category" - /> - <AutocompleteInput - value={this.state.currentMetaData.examtype} - onChange={ev => this.valueChanged("examtype", ev)} - placeholder="examtype" - title="examtype" - autocomplete={this.state.examTypes} - name="examtype" - /> - </div> - <div> - <input - type="text" - placeholder="legacy solution" - title="legacy solution" - value={this.state.currentMetaData.legacy_solution} - onChange={ev => this.valueChanged("legacy_solution", ev)} - /> - <input - type="text" - placeholder="master solution (extern)" - title="master solution (extern)" - value={this.state.currentMetaData.master_solution} - onChange={ev => this.valueChanged("master_solution", ev)} - /> - </div> - <div> - <input - type="text" - placeholder="remark" - title="remark" - value={this.state.currentMetaData.remark} - onChange={ev => this.valueChanged("remark", ev)} - /> - </div> - <div> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.public} - onChange={ev => this.checkboxValueChanged("public", ev)} - /> - Public - </label> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.needs_payment} - onChange={ev => this.checkboxValueChanged("needs_payment", ev)} - /> - Needs Payment - </label> - </div> - <div> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.finished_cuts} - onChange={ev => this.checkboxValueChanged("finished_cuts", ev)} - /> - Finished Cuts - </label> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.finished_wiki_transfer} - onChange={ev => - this.checkboxValueChanged("finished_wiki_transfer", ev) - } - /> - Finished Wiki Transfer - </label> - </div> - <hr /> - <div> - <label> - Print Only File - <input - type="file" - accept="application/pdf" - onChange={this.handleFileChangePrintonly} - /> - </label> - <button onClick={this.uploadFilePrintonly}>Upload</button> - </div> - {this.props.savedMetaData.is_printonly && ( - <div> - <a - {...stylesForWidth.inlineBlock} - href={ - "/api/exam/pdf/printonly/" + - this.props.savedMetaData.filename + - "/" - } - target="_blank" - rel="noopener noreferrer" - > - Current File - </a> - <button onClick={this.removeFilePrintonly}>Remove File</button> - </div> - )} - <div> - <label> - Master Solution - <input - type="file" - accept="application/pdf" - onChange={this.handleFileChangeSolution} - /> - </label> - <button onClick={this.uploadFileSolution}>Upload</button> - </div> - {this.props.savedMetaData.has_solution && ( - <div> - <a - {...stylesForWidth.inlineBlock} - href={ - "/api/exam/pdf/solution/" + - this.props.savedMetaData.filename + - "/" - } - target="_blank" - rel="noopener noreferrer" - > - Current File - </a> - <button onClick={this.removeFileSolution}>Remove File</button> - </div> - )} - {this.props.savedMetaData.has_solution && ( - <div> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.solution_printonly} - onChange={ev => - this.checkboxValueChanged("solution_printonly", ev) - } - /> - Solution Print Only - </label> - </div> - )} - <hr /> - <div {...styles.title}> - <b>Attachments</b> - </div> - <Attachments - attachments={this.props.savedMetaData.attachments} - additionalArgs={{ exam: this.props.filename }} - onAddAttachment={this.addAttachment} - onRemoveAttachment={this.removeAttachment} - /> - <hr /> - {this.state.error && <div>{this.state.error}</div>} - <div> - <button onClick={this.saveEdit}>Save</button> - <button onClick={this.cancelEdit}>Cancel</button> - </div> - </div> - ); - } -} diff --git a/frontend/src/components/notification.tsx b/frontend/src/components/notification.tsx index 35405120baa0090af00aaa025104049e788bbd07..fa4edca696755a3a2cb58d7da535068278116b72 100644 --- a/frontend/src/components/notification.tsx +++ b/frontend/src/components/notification.tsx @@ -1,79 +1,49 @@ -import * as React from "react"; -import { NotificationInfo } from "../interfaces"; +import { Alert, Card, CardBody, CardHeader } from "@vseth/components"; import moment from "moment"; -import { css } from "glamor"; -import { fetchPost } from "../fetch-utils"; -import Colors from "../colors"; +import * as React from "react"; +import { useEffect } from "react"; import { Link } from "react-router-dom"; -import MarkdownText from "./markdown-text"; -import globalcss from "../globalcss"; +import { useMarkAllAsRead } from "../api/hooks"; import GlobalConsts from "../globalconsts"; - +import { NotificationInfo } from "../interfaces"; +import MarkdownText from "./markdown-text"; interface Props { notification: NotificationInfo; } -const styles = { - wrapper: css({ - background: Colors.cardBackground, - padding: "10px", - marginBottom: "20px", - boxShadow: Colors.cardShadow, - maxWidth: "500px", - "@media (max-width: 699px)": { - padding: "5px", - }, - }), - header: css({ - fontSize: "24px", - marginBottom: "10px", - marginLeft: "-10px", - marginRight: "-10px", - marginTop: "-10px", - padding: "10px", - background: Colors.cardHeader, - color: Colors.cardHeaderForeground, - }), - subtitle: css({ - fontSize: "16px", - }), - unread: css({ - fontWeight: "bold", - }), -}; - -export default class NotificationComponent extends React.Component<Props> { - readNotification = (notification: NotificationInfo) => { - fetchPost("/api/notification/setread/" + notification.oid + "/", { - read: true, - }); - }; +const NotificationComponent: React.FC<Props> = ({ notification }) => { + const [error, , markAllAsRead] = useMarkAllAsRead(); + useEffect(() => { + markAllAsRead(notification.oid); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [notification.oid]); - render() { - const { notification } = this.props; - return ( - <div {...styles.wrapper}> - <Link - to={notification.link} - onClick={() => this.readNotification(notification)} - > - <div {...styles.header}> - <div {...(notification.read ? undefined : styles.unread)}> - {notification.title} + return ( + <div> + {error && <Alert color="danger">{error.message}</Alert>} + <Card className="my-2"> + <CardHeader> + <h6> + <Link to={notification.link}>{notification.title}</Link> + <div> + <small> + <Link to={notification.sender}> + {notification.senderDisplayName} + </Link>{" "} + •{" "} + {moment( + notification.time, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatString)} + </small> </div> - <div {...globalcss.noLinkColor} {...styles.subtitle}> - <Link to={notification.sender}> - {notification.senderDisplayName} - </Link>{" "} - •{" "} - {moment(notification.time, GlobalConsts.momentParseString).format( - GlobalConsts.momentFormatString, - )} - </div> - </div> - </Link> - <MarkdownText value={notification.message} /> - </div> - ); - } -} + </h6> + </CardHeader> + <CardBody> + <MarkdownText value={notification.message} /> + </CardBody> + </Card> + </div> + ); +}; +export default NotificationComponent; diff --git a/frontend/src/components/offered-in-editor.tsx b/frontend/src/components/offered-in-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..72ee45c334086202d34cc880d9e1e84c231e67cf --- /dev/null +++ b/frontend/src/components/offered-in-editor.tsx @@ -0,0 +1,65 @@ +import { + Button, + Input, + InputGroup, + InputGroupAddon, + ListGroup, + ListGroupItem, +} from "@vseth/components"; +import React, { useState } from "react"; + +interface OfferedInEditorProps { + offeredIn: Array<readonly [string, string]>; + setOfferedIn: (newOfferedIn: Array<readonly [string, string]>) => void; +} +const OfferedInEditor: React.FC<OfferedInEditorProps> = ({ + offeredIn, + setOfferedIn, +}) => { + const [newMeta1, setNewMeta1] = useState(""); + const [newMeta2, setNewMeta2] = useState(""); + const onAdd = () => { + setNewMeta1(""); + setNewMeta2(""); + setOfferedIn([...offeredIn, [newMeta1, newMeta2]]); + }; + const onRemove = (meta1: string, meta2: string) => { + setOfferedIn( + offeredIn.filter( + ([meta1s, meta2s]) => meta1s !== meta1 || meta2s !== meta2, + ), + ); + }; + return ( + <> + <ListGroup> + {offeredIn.map(([meta1, meta2]) => ( + <ListGroupItem key={`${meta1}-${meta2}`}> + <Button close onClick={() => onRemove(meta1, meta2)} /> + {meta1} {meta2} + </ListGroupItem> + ))} + </ListGroup> + <InputGroup> + <Input + type="text" + placeholder="Meta1" + value={newMeta1} + onChange={e => setNewMeta1(e.currentTarget.value)} + /> + <Input + type="text" + placeholder="Meta2" + value={newMeta2} + onChange={e => setNewMeta2(e.currentTarget.value)} + /> + <InputGroupAddon addonType="append"> + <Button block onClick={onAdd}> + Add + </Button> + </InputGroupAddon> + </InputGroup> + </> + ); +}; +export default OfferedInEditor; diff --git a/frontend/src/components/panel.tsx b/frontend/src/components/panel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d295998300d189f048e1b7e96900132e304f72ea --- /dev/null +++ b/frontend/src/components/panel.tsx @@ -0,0 +1,146 @@ +import { Button, Icon, ICONS } from "@vseth/components"; +import { css, cx, keyframes } from "emotion"; +import React, { CSSProperties } from "react"; +import Transition from "react-transition-group/Transition"; +import GlobalConsts from "../globalconsts"; +const panelStyle = css` + position: fixed; + bottom: 0; + right: 0; + display: flex; + flex-direction: row; + padding: 3.5em 0 3.5em 0; + z-index: ${GlobalConsts.zIndex.panel}; + max-width: 500px; + height: 100%; + box-sizing: border-box; + transition: transform 0.5s; +`; +const iconContainerStyle = css` + display: flex; + flex-direction: column; + justify-content: flex-end; +`; +const closeButtonStyle = css` + display: inline-block; + font-size: 0.5em; + &.btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +`; +const modalWrapper = css` + width: 100%; + height: 100%; + display: flex; + align-items: flex-end; +`; +const modalStyle = css` + max-height: 100%; + overflow: scroll; + &.modal-content { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + & .modal-header { + display: block; + } +`; +interface PanelProps { + isOpen: boolean; + toggle: () => void; + iconPadding?: CSSProperties["padding"]; + buttonText?: string; +} + +const duration = 200; +const enteringAnimation = keyframes` + 0% { + transform: translate(100%); + } + 100% { + transform: translate(0); + } +`; +const exitingAnimation = keyframes` +0% { + transform: translate(0); + } + 100% { + transform: translate(100%); + } +`; + +const transitionStyles = { + entering: { + animation: `${enteringAnimation} ${duration}ms cubic-bezier(0.45, 0, 0.55, 1)`, + }, + entered: { transform: "" }, + exiting: { + animation: `${exitingAnimation} ${duration}ms cubic-bezier(0.45, 0, 0.55, 1)`, + }, + exited: { transform: "translate(100%)" }, +}; + +const Panel: React.FC<PanelProps> = ({ + children, + isOpen, + toggle, + iconPadding = "1em 0", + buttonText, +}) => { + return ( + <> + <div className={panelStyle}> + <div className={iconContainerStyle} style={{ padding: iconPadding }}> + <Button + size="lg" + color="primary" + className={closeButtonStyle} + onClick={toggle} + > + <Icon icon={ICONS["ARROW_LEFT"]} size={24} /> + {buttonText && ( + <div> + <small>{buttonText}</small> + </div> + )} + </Button> + </div> + </div> + <Transition in={isOpen} timeout={duration} unmountOnExit> + {state => ( + <div + className={panelStyle} + style={{ + ...transitionStyles[state as keyof typeof transitionStyles], + }} + > + <div + className={iconContainerStyle} + style={{ padding: iconPadding }} + > + <Button + size="lg" + color="primary" + className={closeButtonStyle} + onClick={toggle} + > + <Icon icon={ICONS["CLOSE"]} size={24} /> + {buttonText && ( + <div> + <small>{buttonText}</small> + </div> + )} + </Button> + </div> + <div className={modalWrapper}> + <div className={cx("modal-content", modalStyle)}>{children}</div> + </div> + </div> + )} + </Transition> + </> + ); +}; +export default Panel; diff --git a/frontend/src/components/pdf-section-canvas-overlay.tsx b/frontend/src/components/pdf-section-canvas-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93dcfe0d5deae18421fbfe75bb40f19c8d907025 --- /dev/null +++ b/frontend/src/components/pdf-section-canvas-overlay.tsx @@ -0,0 +1,139 @@ +import { Badge } from "@vseth/components"; +import React, { + useCallback, + useMemo, + useRef, + useState, + useContext, +} from "react"; +import { determineOptimalCutPositions } from "../pdf/snap"; +import { css } from "emotion"; +import { DebugContext } from "./Debug"; + +const wrapperStyle = css` + width: 100%; + height: 100%; + position: absolute; + top: 0; + touch-action: none; + user-select: none; + cursor: pointer; +`; +const badgeStyle = css` + position: absolute; + top: 0; + left: 0; + transform: translateY(-50%); +`; + +interface Props { + canvas: HTMLCanvasElement; + start: number; + end: number; + isMain: boolean; + + onAddCut: (pos: number) => void; + addCutText?: string; + snap?: boolean; +} +const PdfSectionCanvasOverlay: React.FC<Props> = React.memo( + ({ canvas, start, end, isMain, onAddCut, addCutText, snap = true }) => { + const { viewOptimalCutAreas } = useContext(DebugContext); + const [clientY, setClientY] = useState<number | undefined>(undefined); + const ref = useRef<HTMLDivElement>(null); + const pointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => { + setClientY(e.clientY); + }, []); + const leave = useCallback(() => setClientY(undefined), []); + + const height = ref.current?.getBoundingClientRect().height; + const pos = + ref.current && clientY + ? clientY - ref.current.getBoundingClientRect().top + : undefined; + + const optimalCutAreas = useMemo( + () => determineOptimalCutPositions(canvas, start, end, isMain), + [canvas, start, end, isMain], + ); + const relPos = pos !== undefined && height ? pos / height : undefined; + const [relSnapPos, bad] = + relPos !== undefined && ref.current + ? optimalCutAreas + .flatMap(area => area.snapPoints) + .reduce( + ([prev, prevBad], snap) => + Math.abs(snap - relPos) < Math.abs(prev - relPos) + ? [snap, Math.abs(snap - relPos)] + : [prev, prevBad], + [0, Infinity], + ) + : [0, Infinity]; + const snapPos = height ? relSnapPos * height : undefined; + const snapBad = !snap || bad * (end - start) > 0.03; + const displayPos = snapBad ? pos : snapPos; + const onAdd = () => { + if (displayPos === undefined) return; + if (displayPos < 0) return; + if (height === undefined || displayPos > height) return; + displayPos && onAddCut(displayPos); + }; + return ( + <div + className={wrapperStyle} + onPointerMove={pointerMove} + onPointerLeave={leave} + ref={ref} + onPointerUp={onAdd} + > + {viewOptimalCutAreas && + optimalCutAreas.map(({ start, end, snapPoints }) => ( + <React.Fragment key={`${start}-${end}`}> + <div + className="position-absolute w-100" + style={{ + top: `${start * 100}%`, + height: `${(end - start) * 100}%`, + backgroundColor: "rgba(0,0,0,0.2)", + }} + /> + {snapPoints.map(position => ( + <div + key={position} + className="position-absolute w-100 m-0" + style={{ + height: "1px", + backgroundColor: "var(--info)", + top: `${position * 100}%`, + }} + /> + ))} + </React.Fragment> + ))} + {displayPos !== undefined && ( + <div + style={{ + transform: `translateY(${displayPos}px) translateY(-50%)`, + backgroundColor: + snap && snapBad ? "var(--warning)" : "var(--primary)", + height: "3px", + position: "absolute", + width: "100%", + margin: 0, + }} + id="add-cut" + > + <Badge + color={snap && snapBad ? "warning" : "primary"} + size="lg" + className={badgeStyle} + > + {addCutText} + </Badge> + </div> + )} + </div> + ); + }, +); +export default PdfSectionCanvasOverlay; diff --git a/frontend/src/components/pdf-section-text.tsx b/frontend/src/components/pdf-section-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c715523173c81674df5bf08bdab38bae72812a9f --- /dev/null +++ b/frontend/src/components/pdf-section-text.tsx @@ -0,0 +1,120 @@ +import { TextContent, TextContentItem } from "pdfjs-dist"; +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import PDF from "../pdf/pdf-renderer"; + +const useTextLayer = ( + shouldRender: boolean, + renderer: PDF, + pageNumber: number, + start: number, + end: number, +): TextContent | null => { + const [textContent, setTextContent] = useState<TextContent | null>(null); + const runningRef = useRef(false); + useEffect(() => { + runningRef.current = true; + if (shouldRender) { + (async () => { + const text = await renderer.renderText(pageNumber); + if (!runningRef.current) return; + setTextContent(text); + })(); + } + return () => { + runningRef.current = false; + }; + }, [shouldRender, pageNumber, renderer]); + return textContent; +}; + +interface TextElementProps { + item: TextContentItem; + // tslint:disable-next-line: no-any + styles: any; + view: number[]; + scale: number; +} +const PdfTextElement: React.FC<TextElementProps> = ({ + item, + styles, + view, + scale, +}) => { + const [fontHeightPx, , , offsetY, x, y] = item.transform; + const [xMin, , , yMax] = view; + const top = yMax - (y + offsetY); + const left = x - xMin; + const style = styles[item.fontName] || {}; + const fontName = style.fontFamily || ""; + const fontFamily = `${fontName}, sans-serif`; + const divRef = (ref: HTMLDivElement | null) => { + if (ref === null) return; + const [width, height] = [ref.clientWidth, ref.clientHeight]; + const targetWidth = item.width * scale; + const targetHeight = item.height * scale; + const xScale = targetWidth / width; + const yScale = targetHeight / height; + ref.style.transform = `scaleX(${xScale}) scaleY(${yScale})`; + }; + return ( + <div + style={{ + position: "absolute", + top: `${top * scale}px`, + left: `${left * scale}px`, + fontSize: `${fontHeightPx * scale}px`, + whiteSpace: "pre", + fontFamily, + color: "transparent", + transformOrigin: "left bottom", + }} + ref={divRef} + > + {item.str} + </div> + ); +}; + +interface Props { + page: number; + start: number; + end: number; + renderer: PDF; + view?: number[]; + scale: number; + translateY: number; +} +const PdfSectionText: React.FC<Props> = ({ + page, + start, + end, + renderer, + view, + scale, + translateY, +}) => { + const textContent = useTextLayer(true, renderer, page, start, end); + return ( + <div + className="position-absolute position-top-left" + style={{ + transform: `translateY(-${translateY}px) scale(${scale})`, + transformOrigin: "top left", + display: view ? "block" : "none", + }} + > + {textContent && + textContent.items.map((item, index) => ( + <PdfTextElement + key={index} + item={item} + styles={textContent.styles} + view={view || [0, 0, 0, 0]} + scale={1.0} + /> + ))} + </div> + ); +}; +export default PdfSectionText; diff --git a/frontend/src/components/pdf-section.tsx b/frontend/src/components/pdf-section.tsx deleted file mode 100644 index 6caf5180d78b5acb891f170927511f3f7386bbb9..0000000000000000000000000000000000000000 --- a/frontend/src/components/pdf-section.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import * as React from "react"; -import { PdfSection } from "../interfaces"; -import { SectionRenderer, Dimensions } from "../split-render"; -import { css } from "glamor"; -import Colors from "../colors"; - -interface Props { - setHidden: (newHidden: boolean) => void; - canHide: boolean; - section: PdfSection; - renderer: SectionRenderer; - width: number; - dpr: number; // Device Pixel Ratio - renderText: boolean; - onClick: (ev: React.MouseEvent<HTMLElement>, section: PdfSection) => void; -} - -const styles = { - wrapper: css({ - position: "relative", - boxShadow: Colors.cardShadow, - }), - lastSection: css({ - marginBottom: "40px", - }), - canvas: css({ - display: "block", - userSelect: "none", - }), - textLayer: css({ - position: "absolute", - left: "0", - right: "0", - top: "0", - bottom: "0", - overflow: "hidden", - lineHeight: "1.0", - "& > span": { - color: "transparent", - position: "absolute", - whiteSpace: "pre", - cursor: "text", - transformOrigin: "0% 0%", - }, - }), - hideButton: css({ - position: "absolute", - top: "0", - left: "0", - zIndex: "100", - }), -}; - -export default class PdfSectionComp extends React.Component<Props> { - private canv?: HTMLCanvasElement; - private textWrap?: HTMLDivElement; - private ctx?: CanvasRenderingContext2D; - private observer: IntersectionObserver | undefined; - private visible = true; - private needRender = true; - - componentDidMount() { - this.needRender = true; - this.observer = new IntersectionObserver(this.intersectionChanged, { - threshold: 0, - }); - if (this.canv) { - this.observer.observe(this.canv); - } - } - - componentDidUpdate( - prevProps: Readonly<Props>, - prevState: Readonly<{}>, - ): void { - this.needRender = true; - } - - componentWillUnmount(): void { - if (this.canv && this.observer) { - this.observer.unobserve(this.canv); - } - if (this.visible) { - this.props.renderer.removeVisible( - this.props.section.start, - this.renderCanvas, - ); - } - } - - intersectionChanged = (entries: IntersectionObserverEntry[]) => { - entries.forEach(entry => { - this.visible = entry.isIntersecting; - if (this.visible) { - this.props.renderer.addVisible( - this.props.section.start, - this.renderCanvas, - ); - if (this.needRender) { - this.renderCanvas(); - } - } else { - this.props.renderer.removeVisible( - this.props.section.start, - this.renderCanvas, - ); - } - }); - }; - - renderCanvas = () => { - if (!this.ctx || !this.visible) { - return; - } - - const { section, renderer, dpr } = this.props; - const dim = this.sectionDimensions(); - this.needRender = !renderer.render( - { context: this.ctx, width: dim.width * dpr, height: dim.height * dpr }, - section.start, - section.end, - ); - if (this.textWrap && this.canv) { - this.props.renderer.renderTextLayer( - this.textWrap, - this.canv, - this.props.section.start, - this.props.section.end, - this.props.dpr, - ); - } - }; - - sectionDimensions = (): Dimensions => { - const { section, renderer, width } = this.props; - return renderer.sectionDimensions(section.start, section.end, width); - }; - - saveCanvasRef = (c: HTMLCanvasElement) => { - if (!c) { - return; - } - if (this.observer) { - if (this.canv) { - this.observer.unobserve(this.canv); - } - this.observer.observe(c); - } - this.canv = c; - const ctx = c.getContext("2d"); - if (!ctx) { - // tslint:disable-next-line:no-console - console.error("couldn't create canvas context"); - return; - } - this.ctx = ctx; - if (this.needRender) { - this.renderCanvas(); - } - if (this.textWrap) { - this.props.renderer.renderTextLayer( - this.textWrap, - this.canv, - this.props.section.start, - this.props.section.end, - this.props.dpr, - ); - } - }; - - saveTextRef = (d: HTMLDivElement) => { - if (!d) { - return; - } - this.textWrap = d; - if (this.canv) { - this.props.renderer.renderTextLayer( - this.textWrap, - this.canv, - this.props.section.start, - this.props.section.end, - this.props.dpr, - ); - } - }; - - render() { - const { dpr } = this.props; - const rawDim = this.sectionDimensions(); - return ( - <div - {...styles.wrapper} - {...(this.props.section.end.position === 1 - ? styles.lastSection - : undefined)} - > - {this.props.canHide && - (this.props.section.hidden ? ( - <button - {...styles.hideButton} - onClick={() => this.props.setHidden(false)} - > - Show - </button> - ) : ( - <button - {...styles.hideButton} - onClick={() => this.props.setHidden(true)} - > - Hide - </button> - ))} - <canvas - ref={this.saveCanvasRef} - width={Math.ceil(rawDim.width * dpr)} - height={Math.ceil(rawDim.height * dpr)} - // it would be far nicer to have onClick be undefined if not needed, but ts claims it might be undefined when called... - onClick={ - this.props.onClick && - ((ev: React.MouseEvent<HTMLElement>) => - this.props.onClick(ev, this.props.section)) - } - style={{ - width: Math.ceil(rawDim.width), - height: Math.ceil(rawDim.height), - filter: this.props.section.hidden ? "contrast(0.5)" : undefined, - }} - {...styles.canvas} - /> - {this.props.renderText && ( - <div {...styles.textLayer} ref={this.saveTextRef} /> - )} - </div> - ); - } -} diff --git a/frontend/src/components/print-exam.tsx b/frontend/src/components/print-exam.tsx index 4c56220020552ce460241b4e4daaf9fc5ec53e22..7b42086a03cebbb291f5426d4bbb9c43e7c55331 100644 --- a/frontend/src/components/print-exam.tsx +++ b/frontend/src/components/print-exam.tsx @@ -1,7 +1,6 @@ +import { Button, Card, CardBody, InputField } from "@vseth/components"; import * as React from "react"; -import { css } from "glamor"; -import Colors from "../colors"; -import { fetchPost } from "../fetch-utils"; +import { fetchPost } from "../api/fetch-utils"; interface Props { title: string; @@ -15,30 +14,6 @@ interface State { error?: string; } -const styles = { - wrapper: css({ - width: "430px", - margin: "auto", - marginBottom: "20px", - padding: "10px", - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - "@media (max-width: 699px)": { - padding: "5px", - }, - }), - passwordWrapper: css({ - margin: "auto", - width: "50%", - }), - passwordBox: css({ - width: "100%", - }), - printButton: css({ - width: "100%", - }), -}; - export default class PrintExam extends React.Component<Props, State> { state: State = { printed: false, @@ -51,11 +26,7 @@ export default class PrintExam extends React.Component<Props, State> { }); if (this.state.currentPassword.length > 0) { fetchPost( - "/api/exam/printpdf/" + - this.props.examtype + - "/" + - this.props.filename + - "/", + `/api/exam/printpdf/${this.props.examtype}/${this.props.filename}/`, { password: this.state.currentPassword }, ) .then(() => { @@ -77,42 +48,44 @@ export default class PrintExam extends React.Component<Props, State> { render() { return ( - <div {...styles.wrapper}> - <p> - Unfortunately we can not provide you this {this.props.title} as a PDF. - The corresponding professor did not allow this. - </p> - <p> - Warning: The ETH Print Service may generate cost after a certain - number of free pages. - <br /> - More Information:{" "} - <a href="https://printing.sp.ethz.ch/ethps4s"> - https://printing.sp.ethz.ch/ethps4s - </a> - </p> - {this.state.error && <p>{this.state.error}</p>} - {(!this.state.printed && ( - <div {...styles.passwordWrapper}> - <label> - Password <br /> - <input - {...styles.passwordBox} - name="password" - type="password" - onChange={ev => - this.setState({ currentPassword: ev.target.value }) - } - value={this.state.currentPassword} - /> - </label> + <Card className="m-1"> + <CardBody> + <p> + Unfortunately we can not provide you this {this.props.title} as a + PDF. The corresponding professor did not allow this. + </p> + <p> + Warning: The ETH Print Service may generate cost after a certain + number of free pages. <br /> - <button {...styles.printButton} onClick={this.printExam}> - Print {this.props.title} - </button> - </div> - )) || <p>Exam successfully printed</p>} - </div> + More Information:{" "} + <a href="https://printing.sp.ethz.ch/ethps4s"> + https://printing.sp.ethz.ch/ethps4s + </a> + </p> + {this.state.error && <p>{this.state.error}</p>} + {(!this.state.printed && ( + <> + <div> + <InputField + label="Password" + name="password" + type="password" + onChange={ev => + this.setState({ currentPassword: ev.target.value }) + } + value={this.state.currentPassword} + /> + </div> + <div> + <Button onClick={this.printExam}> + Print {this.props.title} + </Button> + </div> + </> + )) || <p>Exam successfully printed</p>} + </CardBody> + </Card> ); } } diff --git a/frontend/src/components/score.tsx b/frontend/src/components/score.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c83bebefe9e8cf5a02be7397815250f997bb8de0 --- /dev/null +++ b/frontend/src/components/score.tsx @@ -0,0 +1,64 @@ +import { useRequest } from "@umijs/hooks"; +import { ButtonGroup, Icon, ICONS, Spinner } from "@vseth/components"; +import React from "react"; +import { fetchPost } from "../api/fetch-utils"; +import { AnswerSection } from "../interfaces"; +import SmallButton from "./small-button"; + +const setLikeReq = async (oid: string, like: -1 | 0 | 1) => { + return (await fetchPost(`/api/exam/setlike/${oid}/`, { like })) + .value as AnswerSection; +}; + +interface Props { + oid: string; + upvotes: number; + expertUpvotes: number; + userVote: -1 | 0 | 1; + onSectionChanged: (newSection: AnswerSection) => void; +} +const Score: React.FC<Props> = ({ + oid, + upvotes, + expertUpvotes, + userVote, + onSectionChanged, +}) => { + const { loading, run: setLike } = useRequest(setLikeReq, { + manual: true, + onSuccess: onSectionChanged, + }); + return ( + <ButtonGroup className="m-1"> + <SmallButton + tooltip="Downvote" + size="sm" + disabled={userVote === -1} + outline={userVote === -1} + onClick={() => setLike(oid, -1)} + > + <Icon icon={ICONS.MINUS} size={18} /> + </SmallButton> + <SmallButton + tooltip="Reset vote" + size="sm" + className="text-dark" + disabled={userVote === 0} + outline + onClick={() => setLike(oid, 0)} + > + {loading ? <Spinner size="sm" /> : upvotes} + </SmallButton> + <SmallButton + tooltip="Upvote" + size="sm" + disabled={userVote === 1} + outline={userVote === 1} + onClick={() => setLike(oid, 1)} + > + <Icon icon={ICONS.PLUS} size={18} /> + </SmallButton> + </ButtonGroup> + ); +}; +export default Score; diff --git a/frontend/src/components/secondary-container.tsx b/frontend/src/components/secondary-container.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6670df1882f8ec0605b4caa90c5af038a57bb9c6 --- /dev/null +++ b/frontend/src/components/secondary-container.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { css } from "emotion"; +interface Props {} +const contentContainerBg = css` + background-color: #fafafa; +`; +const ContentContainer: React.FC<Props> = ({ children }) => { + return ( + <div + className={`border-gray-300 border-top border-bottom py-5 px-0 my-3 ${contentContainerBg}`} + > + {children} + </div> + ); +}; +export default ContentContainer; diff --git a/frontend/src/components/small-button.tsx b/frontend/src/components/small-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0b83d462f22c5f53e74f92ead05f6607f15c6ef --- /dev/null +++ b/frontend/src/components/small-button.tsx @@ -0,0 +1,8 @@ +import styled from "@emotion/styled"; +import TooltipButton from "./TooltipButton"; + +const SmallButton = styled(TooltipButton)` + min-width: 0; +`; + +export default SmallButton; diff --git a/frontend/src/components/table-of-contents.tsx b/frontend/src/components/table-of-contents.tsx index 9575fe2cb8a3378d4ef2a87f8ba3e9b83a1dad0a..746894e1339458f4e636a2db449a558ae69ebac6 100644 --- a/frontend/src/components/table-of-contents.tsx +++ b/frontend/src/components/table-of-contents.tsx @@ -1,22 +1,14 @@ import { Link } from "react-router-dom"; import * as React from "react"; import { useState } from "react"; -import { css } from "glamor"; -import Colors from "../colors"; - -const wrapperStyle = css({ - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - width: "100%", - maxWidth: "500px", - margin: "auto", - marginBottom: "20px", - padding: "5px 10px", - boxSizing: "border-box", -}); -const centerStyle = css({ - textAlign: "center", -}); +import { + Card, + CardHeader, + CardBody, + Button, + Row, + Col, +} from "@vseth/components"; export class TOCNode { name: string; @@ -70,27 +62,37 @@ interface Props { export const TOC: React.FC<Props> = ({ toc }) => { const [visible, setVisible] = useState(false); return visible ? ( - <div {...wrapperStyle}> - <div {...centerStyle}> - <h3> - Contents - <button onClick={() => setVisible(false)}>Hide</button> - </h3> - </div> - <ul> - {toc.children.map((child, i) => ( - <TOCNodeComponent node={child} key={child.name + i} /> - ))} - </ul> - </div> + <Card className="m-1"> + <CardHeader> + <Row className="flex-between"> + <Col xs="auto" className="d-flex flex-center flex-column"> + <h6 className="m-0">Contents</h6> + </Col> + <Col xs="auto"> + <Button onClick={() => setVisible(false)}>Hide</Button> + </Col> + </Row> + </CardHeader> + <CardBody> + <ul> + {toc.children.map((child, i) => ( + <TOCNodeComponent node={child} key={child.name + i} /> + ))} + </ul> + </CardBody> + </Card> ) : ( - <div {...wrapperStyle}> - <div {...centerStyle}> - <h3> - Contents - <button onClick={() => setVisible(true)}>Show</button> - </h3>{" "} - </div> - </div> + <Card className="m-1"> + <CardHeader> + <Row className="flex-between"> + <Col xs="auto" className="d-flex flex-center flex-column"> + <h6 className="m-0">Contents</h6> + </Col> + <Col xs="auto"> + <Button onClick={() => setVisible(true)}>Show</Button> + </Col> + </Row> + </CardHeader> + </Card> ); }; diff --git a/frontend/src/components/text-link.tsx b/frontend/src/components/text-link.tsx deleted file mode 100644 index 28a2e6747deea55431472c9f0f01baec720ad478..0000000000000000000000000000000000000000 --- a/frontend/src/components/text-link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { css } from "glamor"; -import { Link, LinkProps } from "react-router-dom"; -import * as React from "react"; - -const styles = { - inheritStyle: css({ - color: "inherit", - textDecoration: "inherit", - "&:link": { - color: "inherit", - textDecoration: "inherit", - }, - "&:hover": { - color: "inherit", - textDecoration: "inherit", - }, - "&:visited": { - color: "inherit", - textDecoration: "inherit", - }, - }), -}; -export const TextLink: React.FC<LinkProps> = props => { - return <Link {...props} {...styles.inheritStyle} />; -}; -export default TextLink; diff --git a/frontend/src/components/three-columns.tsx b/frontend/src/components/three-columns.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b66d29fd913f58a8919ef4f681178cc8934e412c --- /dev/null +++ b/frontend/src/components/three-columns.tsx @@ -0,0 +1,25 @@ +import { Col, Container, Row } from "@vseth/components"; +import React from "react"; + +const ThreeColumns: React.FC<{ + left?: React.ReactNode; + center?: React.ReactNode; + right?: React.ReactNode; +}> = ({ left, center, right }) => { + return ( + <Container fluid className="p-0"> + <Row> + <Col xs={4} className="px-0 text-left"> + {left} + </Col> + <Col xs={4} className="px-0 text-center"> + {center} + </Col> + <Col xs={4} className="px-0 text-right"> + {right} + </Col> + </Row> + </Container> + ); +}; +export default ThreeColumns; diff --git a/frontend/src/components/upload-pdf-card.tsx b/frontend/src/components/upload-pdf-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..788b4369422271930ddc7a175ae1405adc0cc5f2 --- /dev/null +++ b/frontend/src/components/upload-pdf-card.tsx @@ -0,0 +1,99 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Card, + CardBody, + CardHeader, + Col, + Form, + FormGroup, + InputField, + Row, + Select, + Spinner, +} from "@vseth/components"; +import React, { useMemo, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { loadCategories, uploadPdf } from "../api/hooks"; +import FileInput from "./file-input"; + +const UploadPdfCard: React.FC<{}> = () => { + const history = useHistory(); + const { + error: categoriesError, + loading: categoriesLoading, + data: categories, + } = useRequest(loadCategories); + const { + error: uploadError, + loading: uploadLoading, + run: upload, + } = useRequest(uploadPdf, { + manual: true, + onSuccess: filename => history.push(`/exams/${filename}`), + }); + const [validationError, setValidationError] = useState(""); + const error = categoriesError || uploadError || validationError; + const loading = categoriesLoading || uploadLoading; + const options = useMemo( + () => + categories?.map(category => ({ + value: category.slug, + label: category.displayname, + })), + [categories], + ); + const [file, setFile] = useState<File | undefined>(); + const [displayname, setDisplayname] = useState(""); + const [category, setCategory] = useState<string | undefined>(); + const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (file && category) { + upload(file, displayname, category); + } else if (file === undefined) { + setValidationError("No file selected"); + } else { + setValidationError("No category selected"); + } + }; + return ( + <Card> + <CardHeader>Upload PDF</CardHeader> + <CardBody> + <Form onSubmit={onSubmit}> + {error && <Alert color="danger">{error.toString()}</Alert>} + <label className="form-input-label">File</label> + <FileInput value={file} onChange={setFile} accept="application/pdf" /> + <InputField + label="Name" + type="text" + placeholder="Name" + value={displayname} + onChange={e => setDisplayname(e.currentTarget.value)} + required + /> + <FormGroup> + <label className="form-input-label">Category</label> + <Select + options={options} + onChange={(e: any) => setCategory(e.value as string)} + isLoading={categoriesLoading} + required + /> + </FormGroup> + <Row form> + <Col md={4}> + <FormGroup> + <Button color="primary" type="submit" disabled={loading}> + {uploadLoading ? <Spinner /> : "Submit"} + </Button> + </FormGroup> + </Col> + </Row> + </Form> + </CardBody> + </Card> + ); +}; +export default UploadPdfCard; diff --git a/frontend/src/components/upload-transcript-card.tsx b/frontend/src/components/upload-transcript-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..787db03de53dd0b93456ea4b81b4c696a8434032 --- /dev/null +++ b/frontend/src/components/upload-transcript-card.tsx @@ -0,0 +1,96 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Card, + CardBody, + CardHeader, + Col, + Form, + FormGroup, + Row, + Select, + Spinner, +} from "@vseth/components"; +import React, { useMemo, useState } from "react"; +import { useHistory } from "react-router-dom"; +import { loadPaymentCategories, uploadTranscript } from "../api/hooks"; +import FileInput from "./file-input"; + +const UploadTranscriptCard: React.FC<{}> = () => { + const history = useHistory(); + const { + error: categoriesError, + loading: categoriesLoading, + data: categories, + } = useRequest(loadPaymentCategories); + const { + error: uploadError, + loading: uploadLoading, + run: upload, + } = useRequest(uploadTranscript, { + manual: true, + onSuccess: filename => history.push(`/exams/${filename}`), + }); + const [validationError, setValidationError] = useState(""); + const error = categoriesError || uploadError || validationError; + const loading = categoriesLoading || uploadLoading; + + const options = useMemo( + () => + categories?.map(category => ({ + value: category.slug, + label: category.displayname, + })), + [categories], + ); + + const [file, setFile] = useState<File | undefined>(); + const [category, setCategory] = useState<string | undefined>(); + const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (file && category) { + upload(file, category); + } else if (file === undefined) { + setValidationError("No file selected"); + } else { + setValidationError("No category selected"); + } + }; + + return ( + <Card> + <CardHeader>Submit Transcript for Oral Exam</CardHeader> + <CardBody> + <p> + Please use the following{" "} + <a href="/static/transcript_template.tex">template</a>. + </p> + <Form onSubmit={onSubmit}> + {error && <Alert color="danger">{error.toString()}</Alert>} + <label className="form-input-label">File</label> + <FileInput value={file} onChange={setFile} accept="application/pdf" /> + <FormGroup> + <label className="form-input-label">Category</label> + <Select + options={options} + onChange={(e: any) => setCategory(e.value as string)} + isLoading={categoriesLoading} + required + /> + </FormGroup> + <Row form> + <Col md={4}> + <FormGroup> + <Button color="primary" type="submit" disabled={loading}> + {uploadLoading ? <Spinner /> : "Submit"} + </Button> + </FormGroup> + </Col> + </Row> + </Form> + </CardBody> + </Card> + ); +}; +export default UploadTranscriptCard; diff --git a/frontend/src/components/user-answers.tsx b/frontend/src/components/user-answers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b3ae21f0d1709f95d020c38ec4408c9b1b428d5 --- /dev/null +++ b/frontend/src/components/user-answers.tsx @@ -0,0 +1,28 @@ +import { useUserAnswers } from "../api/hooks"; +import React from "react"; +import { Alert, Spinner } from "@vseth/components"; +import AnswerComponent from "./answer"; + +interface UserAnswersProps { + username: string; +} +const UserAnswers: React.FC<UserAnswersProps> = ({ username }) => { + const [error, loading, answers, reload] = useUserAnswers(username); + return ( + <> + <h2>Answers</h2> + {error && <Alert color="danger">{error.message}</Alert>} + {loading && <Spinner />} + {answers && + answers.map(answer => ( + <AnswerComponent + key={answer.oid} + answer={answer} + isLegacyAnswer={answer.isLegacyAnswer} + onSectionChanged={reload} + /> + ))} + </> + ); +}; +export default UserAnswers; diff --git a/frontend/src/components/user-notifications.tsx b/frontend/src/components/user-notifications.tsx new file mode 100644 index 0000000000000000000000000000000000000000..115b6cd1ef8c5d7c0902f1151d5da0801a2357af --- /dev/null +++ b/frontend/src/components/user-notifications.tsx @@ -0,0 +1,89 @@ +import { Alert, Button, FormGroup, Label, Spinner } from "@vseth/components"; +import React, { useState } from "react"; +import { + useEnabledNotifications, + useNotifications, + useSetEnabledNotifications, +} from "../api/hooks"; +import { useUser } from "../auth"; +import NotificationComponent from "./notification"; + +interface UserNotificationsProps { + username: string; +} +const UserNotifications: React.FC<UserNotificationsProps> = ({ username }) => { + const user = useUser()!; + const isMyself = username === user.username; + const [showRead, setShowRead] = useState(false); + const [ + notificationsError, + notificationsLoading, + notifications, + ] = useNotifications(showRead ? "all" : "unread"); + const [ + enabledError, + enabledLoading, + enabled, + reloadEnabled, + ] = useEnabledNotifications(isMyself); + const [ + setEnabledError, + setEnabledLoading, + setEnabled, + ] = useSetEnabledNotifications(reloadEnabled); + const error = notificationsError || enabledError || setEnabledError; + const checkboxLoading = enabledLoading || setEnabledLoading; + return ( + <> + <h2>Notifications</h2> + {error && <Alert color="danger">{error.toString()}</Alert>} + <FormGroup check> + <Label> + <input + type="checkbox" + checked={enabled ? enabled.has(1) : false} + disabled={checkboxLoading} + onChange={e => setEnabled(1, e.currentTarget.checked)} + />{" "} + Comment to my answer + </Label> + </FormGroup> + <FormGroup check> + <Label> + <input + type="checkbox" + checked={enabled ? enabled.has(2) : false} + disabled={checkboxLoading} + onChange={e => setEnabled(2, e.currentTarget.checked)} + />{" "} + Comment to my comment + </Label> + </FormGroup> + <FormGroup check> + <Label> + <input + type="checkbox" + checked={enabled ? enabled.has(3) : false} + disabled={checkboxLoading} + onChange={e => setEnabled(3, e.currentTarget.checked)} + />{" "} + Other answer to same question + </Label> + </FormGroup> + {notificationsLoading && <Spinner />} + {notifications && + notifications.map(notification => ( + <NotificationComponent + notification={notification} + key={notification.oid} + /> + ))} + <div> + <Button onClick={() => setShowRead(prev => !prev)}> + {showRead ? "Hide Read Notifications" : "Show Read Notifications"} + </Button> + </div> + </> + ); +}; +export default UserNotifications; diff --git a/frontend/src/components/user-payments.tsx b/frontend/src/components/user-payments.tsx new file mode 100644 index 0000000000000000000000000000000000000000..466a79841feb9d3c5f55ee5ebecb3ccdcfba359d --- /dev/null +++ b/frontend/src/components/user-payments.tsx @@ -0,0 +1,134 @@ +import { + Alert, + Button, + Container, + ListGroup, + ListGroupItem, + Spinner, +} from "@vseth/components"; +import moment from "moment"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { useUser } from "../auth"; +import GlobalConsts from "../globalconsts"; +import { + useAddPayments, + usePayments, + useRefundPayment, + useRemovePayment, +} from "../api/hooks"; +import Grid from "./grid"; + +interface UserPaymentsProps { + username: string; +} +const UserPayments: React.FC<UserPaymentsProps> = ({ username }) => { + const user = useUser()!; + const isAdmin = user.isAdmin; + const isMyself = username === user.username; + const [ + paymentsError, + paymentsLoading, + payments, + reloadPayments, + ] = usePayments(username, isMyself); + const [refundError, refundLoading, refund] = useRefundPayment(reloadPayments); + const [removeError, removeLoading, remove] = useRemovePayment(reloadPayments); + const [addError, addLoading, add] = useAddPayments(reloadPayments); + const error = paymentsError || refundError || removeError || addError; + const loading = + paymentsLoading || refundLoading || removeLoading || addLoading; + const [openPayment, setOpenPayment] = useState(""); + return ( + <> + {error && <Alert color="danger">{error.toString()}</Alert>} + {loading && <Spinner />} + {payments && (payments.length > 0 || isAdmin) && ( + <> + <h2>Paid Oral Exams</h2> + {payments + .filter(payment => payment.active) + .map(payment => ( + <Alert key={payment.oid}> + You have paid for all oral exams until{" "} + {moment( + payment.valid_until, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatStringDate)} + . + </Alert> + ))} + <Grid> + {payments.map(payment => + openPayment === payment.oid ? ( + <ListGroup key={payment.oid} onClick={() => setOpenPayment("")}> + <ListGroupItem> + Payment Time:{" "} + {moment( + payment.payment_time, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatString)} + </ListGroupItem> + <ListGroupItem> + Valid Until:{" "} + {moment( + payment.valid_until, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatStringDate)} + </ListGroupItem> + {payment.refund_time && ( + <ListGroupItem> + Refund Time:{" "} + {moment( + payment.refund_time, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatString)} + </ListGroupItem> + )} + {payment.uploaded_filename && ( + <ListGroupItem> + <Link to={`/exams/${payment.uploaded_filename}`}> + Uploaded Transcript + </Link> + </ListGroupItem> + )} + {!payment.refund_time && isAdmin && ( + <ListGroupItem> + <Button onClick={() => refund(payment.oid)}> + Mark Refunded + </Button> + <Button onClick={() => remove(payment.oid)}> + Remove Payment + </Button> + </ListGroupItem> + )} + </ListGroup> + ) : ( + <ListGroup + key={payment.oid} + onClick={() => setOpenPayment(payment.oid)} + > + <ListGroupItem> + Payment Time:{" "} + {moment( + payment.payment_time, + GlobalConsts.momentParseString, + ).format(GlobalConsts.momentFormatString)} + </ListGroupItem> + </ListGroup> + ), + )} + </Grid> + </> + )} + {isAdmin && + payments && + payments.filter(payment => payment.active).length === 0 && ( + <Container fluid> + <Button onClick={() => add(username)}>Add Payment</Button> + </Container> + )} + </> + ); +}; +export default UserPayments; diff --git a/frontend/src/components/user-score-card.tsx b/frontend/src/components/user-score-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c9a169b839594e51a2e5624eb954501731ed4297 --- /dev/null +++ b/frontend/src/components/user-score-card.tsx @@ -0,0 +1,104 @@ +import { + Alert, + Button, + Card, + CardFooter, + Col, + Container, + Row, +} from "@vseth/components"; +import React from "react"; +import { notLoggedIn, useSetUser } from "../auth"; +import { useLogout } from "../api/hooks"; +import { UserInfo } from "../interfaces"; +import LoadingOverlay from "./loading-overlay"; + +interface UserScoreCardProps { + username?: string; + userInfo?: UserInfo; + isMyself: boolean; +} +const UserScoreCard: React.FC<UserScoreCardProps> = ({ + username, + userInfo, + isMyself, +}) => { + const setUser = useSetUser(); + const [logoutError, logoutLoading, logout] = useLogout(() => + setUser(notLoggedIn), + ); + return ( + <> + {logoutError && <Alert color="danger">{logoutError.message}</Alert>} + <Row> + <Col> + <h1>{userInfo?.displayName || username}</h1> + </Col> + <Col xs="auto"> + {isMyself && ( + <Button disabled={logoutLoading} onClick={logout}> + Logout + </Button> + )} + </Col> + </Row> + + <Container fluid> + <Row> + <Col md={6} lg={4}> + <Card className="m-1"> + <LoadingOverlay loading={!userInfo} /> + <h3 className="p-4 m-0">{userInfo ? userInfo.score : "-"}</h3> + <CardFooter tag="h6" className="m-0"> + Score + </CardFooter> + </Card> + </Col> + <Col md={6} lg={4}> + <Card className="m-1"> + <LoadingOverlay loading={!userInfo} /> + <h3 className="p-4 m-0"> + {userInfo ? userInfo.score_answers : "-"} + </h3> + <CardFooter tag="h6" className="m-0"> + Answers + </CardFooter> + </Card> + </Col> + <Col md={6} lg={4}> + <Card className="m-1"> + <LoadingOverlay loading={!userInfo} /> + <h3 className="p-4 m-0"> + {userInfo ? userInfo.score_comments : "-"} + </h3> + <CardFooter tag="h6" className="m-0"> + Comments + </CardFooter> + </Card> + </Col> + {userInfo && userInfo.score_cuts > 0 && ( + <Col md={6} lg={4}> + <Card className="m-1"> + <h3 className="p-4 m-0">{userInfo.score_cuts}</h3> + <CardFooter tag="h6" className="m-0"> + Exam Import + </CardFooter> + </Card> + </Col> + )} + {userInfo && userInfo.score_legacy > 0 && ( + <Col md={6} lg={4}> + <Card className="m-1"> + <h3 className="p-4 m-0">{userInfo.score_legacy}</h3> + <CardFooter tag="h6" className="m-0"> + Wiki Import + </CardFooter> + </Card> + </Col> + )} + </Row> + </Container> + </> + ); +}; +export default UserScoreCard; diff --git a/frontend/src/components/user-set-editor.tsx b/frontend/src/components/user-set-editor.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a888e980e70f33ad0af284055bf35c20c354fbd --- /dev/null +++ b/frontend/src/components/user-set-editor.tsx @@ -0,0 +1,51 @@ +import { + Button, + Input, + InputGroup, + InputGroupAddon, + ListGroup, + ListGroupItem, +} from "@vseth/components"; +import React, { useState } from "react"; + +interface UserSetEditorProps { + users: string[]; + setUsers: (newUsers: string[]) => void; +} +const UserSetEditor: React.FC<UserSetEditorProps> = ({ users, setUsers }) => { + const [username, setUsername] = useState(""); + const onAdd = () => { + if (users.includes(username)) return; + setUsername(""); + setUsers([...users, username]); + }; + const remove = (username: string) => { + setUsers(users.filter(un => un !== username)); + }; + return ( + <> + <ListGroup> + {users.map(user => ( + <ListGroupItem key={user}> + <Button close onClick={() => remove(user)} /> + {user} + </ListGroupItem> + ))} + </ListGroup> + <InputGroup> + <Input + type="text" + placeholder="Name" + value={username} + onChange={e => setUsername(e.currentTarget.value)} + /> + <InputGroupAddon addonType="append"> + <Button block onClick={onAdd}> + Add + </Button> + </InputGroupAddon> + </InputGroup> + </> + ); +}; +export default UserSetEditor; diff --git a/frontend/src/globalconsts.ts b/frontend/src/globalconsts.ts index 305ca8e32cbc31099222f725514d50abc8a7b927..4aed6942e430d50414ec2747bb4455f1e52e8367 100644 --- a/frontend/src/globalconsts.ts +++ b/frontend/src/globalconsts.ts @@ -1,7 +1,14 @@ +enum ZIndex { + imageOverlay = 42, + panel = 100, + tutorialSlideShow, + tutorialSlideShowOverlayArea, +} export default class GlobalConsts { static readonly momentParseString = "YYYY-MM-DDTHH:mm:ss.SSSSSSZZ"; static readonly momentFormatString = "DD.MM.YYYY HH:mm"; static readonly momentFormatStringDate = "DD.MM.YYYY"; static readonly mediaSmall = "@media (max-width: 599px)"; static readonly mediaMedium = "@media (max-width: 799px)"; + static readonly zIndex = ZIndex; } diff --git a/frontend/src/globalcss.ts b/frontend/src/globalcss.ts deleted file mode 100644 index 109887de2b8ae02c23b64b16c4b31b530ad14f27..0000000000000000000000000000000000000000 --- a/frontend/src/globalcss.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { css } from "glamor"; -import Colors from "./colors"; - -export default class GlobalCSS { - static readonly noLinkColor = css({ - "& a": { - ":link": { - color: "inherit", - }, - ":visited": { - color: "inherit", - }, - }, - }); - - static readonly button = { - cursor: "pointer", - background: Colors.buttonBackground, - padding: "7px 14px", - border: "1px solid " + Colors.buttonBorder, - textAlign: "center", - textDecoration: "none", - display: "inline-block", - borderRadius: "2px", - margin: "5px", - }; - - static readonly buttonCss = css(GlobalCSS.button); -} diff --git a/frontend/src/hooks/useAlmostInViewport.ts b/frontend/src/hooks/useAlmostInViewport.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae6695fe6024331a72cd2c693a438c60e2361808 --- /dev/null +++ b/frontend/src/hooks/useAlmostInViewport.ts @@ -0,0 +1,128 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +// Modified version of https://github.com/umijs/hooks/blob/master/packages/hooks/src/useInViewport/index.ts +/* +MIT License + +Copyright (c) 2019 umijs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import { useRef, useLayoutEffect, useState, MutableRefObject } from "react"; + +import "intersection-observer"; +const radius = 500; + +type Arg = HTMLElement | (() => HTMLElement) | null; +type InViewport = boolean | undefined; + +function isInViewPort(el: HTMLElement): boolean { + if (!el) { + return false; + } + + const viewPortWidth = + window.innerWidth || + document.documentElement.clientWidth || + document.body.clientWidth; + const viewPortHeight = + window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight; + const rect = el.getBoundingClientRect(); + + if (rect) { + const { top, bottom, left, right } = { + top: rect.top - radius, + bottom: rect.bottom + radius, + left: rect.left - radius, + right: rect.right + radius, + }; + return ( + bottom > 0 && top <= viewPortHeight && left <= viewPortWidth && right > 0 + ); + } + + return false; +} + +function useAlmostInViewport<T extends HTMLElement = HTMLElement>(): [ + InViewport, + MutableRefObject<T>, +]; +function useAlmostInViewport<T extends HTMLElement = HTMLElement>( + arg: Arg, +): [InViewport]; +function useAlmostInViewport<T extends HTMLElement = HTMLElement>( + ...args: [Arg] | [] +): [InViewport, MutableRefObject<T>?] { + const element = useRef<T>(); + const hasPassedInElement = args.length === 1; + const arg = useRef(args[0]); + [arg.current] = args; + const [inViewPort, setInViewport] = useState<InViewport>(() => { + const initDOM = + typeof arg.current === "function" ? arg.current() : arg.current; + + return isInViewPort(initDOM as HTMLElement); + }); + + useLayoutEffect(() => { + const passedInElement = + typeof arg.current === "function" ? arg.current() : arg.current; + + const targetElement = hasPassedInElement + ? passedInElement + : element.current; + + if (!targetElement) { + return () => {}; + } + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + setInViewport(true); + } else { + setInViewport(false); + } + } + }, + { rootMargin: `${radius}px ${radius}px ${radius}px ${radius}px` }, + ); + + observer.observe(targetElement); + + return () => { + observer.disconnect(); + }; + }, [ + element.current, + typeof arg.current === "function" ? undefined : arg.current, + ]); + + if (hasPassedInElement) { + return [inViewPort]; + } + + return [inViewPort, element as MutableRefObject<T>]; +} + +export default useAlmostInViewport; diff --git a/frontend/src/hooks/useConfirm.tsx b/frontend/src/hooks/useConfirm.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2473d35de984ff9d21539509b59dd1e07da638c2 --- /dev/null +++ b/frontend/src/hooks/useConfirm.tsx @@ -0,0 +1,39 @@ +import React, { useState, useCallback } from "react"; +import { Modal, ModalBody, ModalFooter, Button } from "@vseth/components"; +type CB = () => void; +const useConfirm = () => { + const [stack, setStack] = useState<Array<[string, CB, CB]>>([]); + const push = useCallback((message: string, yes: CB, no?: CB) => { + setStack(prevStack => [...prevStack, [message, yes, no || (() => {})]]); + }, []); + const pop = useCallback(() => { + setStack(prevStack => prevStack.slice(0, prevStack.length - 1)); + }, []); + const modals = stack.map(([message, yes, no], i) => ( + <Modal isOpen={true} key={i + message}> + <ModalBody>{message}</ModalBody> + <ModalFooter> + <Button + color="secondary" + onClick={() => { + pop(); + no(); + }} + > + Cancel + </Button> + <Button + color="primary" + onClick={() => { + pop(); + yes(); + }} + > + Okay + </Button> + </ModalFooter> + </Modal> + )); + return [push, modals] as const; +}; +export default useConfirm; diff --git a/frontend/src/hooks/useDpr.ts b/frontend/src/hooks/useDpr.ts new file mode 100644 index 0000000000000000000000000000000000000000..366f685928819583338add5642d3443b8b7241cb --- /dev/null +++ b/frontend/src/hooks/useDpr.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +const useDpr = () => { + const [dpr, setDpr] = useState(window.devicePixelRatio); + useEffect(() => { + const listener = () => { + setDpr(window.devicePixelRatio); + }; + const media = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + media.addListener(listener); + return () => { + media.removeListener(listener); + }; + }, [dpr]); + return dpr; +}; +export default useDpr; diff --git a/frontend/src/hooks/useForm.ts b/frontend/src/hooks/useForm.ts new file mode 100644 index 0000000000000000000000000000000000000000..8682d68c9812824f7d607705e3c7e935c39302ee --- /dev/null +++ b/frontend/src/hooks/useForm.ts @@ -0,0 +1,165 @@ +import React, { useCallback, useDebugValue, useRef, useState } from "react"; + +type KeysWhereValue<T, S> = { + [K in keyof T]: T[K] extends S ? K : never; +}[keyof T]; +interface FormData { + [name: string]: any; +} +interface InputElement<T> { + value: T; + defaultValue: T; +} +interface CheckedElement { + checked: boolean; + defaultChecked: boolean; +} +type ResetFnMap<T extends FormData> = { + [name in keyof T]?: () => void; +}; +/** + * A hook which can be used to manage a form that consists of + * both uncontrolled and controlled input elements. Uncontrolled + * input elements are generally preferred. + * @param initialData The initial data the form should be populated + * with. Include keys that are initially undefined as this value + * will be used to infer the form data type. + * @param formSubmit An optional callback that is invoked with the + * current data when the returned onSubmit is invoked. + * @param _state An array of keys that are controlled by controlled + * input elements. Currently this array is only used for type-checking + * @example + * const { + * registerInput, + * registerCheckbox, + * onSubmit + * } = useForm({ + * name: "", + * password: "", + * }, + * ({name, password}) => + * login(name, password), + * [] + * ); + */ +const useForm = < + S extends keyof T, + T extends FormData, + K extends (data: T, reset: () => void) => any +>( + initialData: T, + formSubmit?: K, + _state: S[] = [], +) => { + const ref = useRef({ ...initialData }); + useDebugValue(ref.current); + const [state, setState] = useState<Pick<T, S>>({ ...initialData }); + const resetFnMap = useRef<ResetFnMap<T>>({}); + const getValues = useCallback(() => { + return ref.current; + }, []); + const setValue = useCallback(<A extends keyof T>(name: A, value: T[A]) => { + ref.current[name] = value; + }, []); + const onChangeTransform = useCallback( + <B extends keyof T, M, A extends React.ChangeEvent<M>>( + name: B, + e: A, + transform: (val: M) => T[B], + ) => { + setValue(name, transform(e.target)); + }, + [setValue], + ); + const setStateValue = useCallback( + <K extends S>(key: K, value: T[S]) => { + setValue(key, value); + setState(prevState => ({ ...prevState, [key]: value })); + }, + [setValue], + ); + const register = useCallback( + <K extends keyof T, M, S>( + name: K, + transform: (val: M) => T[K], + valueTransform: (val: T[K]) => S, + ) => { + const handler = <A extends React.ChangeEvent<M>>(e: A) => + onChangeTransform(name, e, transform); + return { + ...valueTransform(getValues()[name]), + onChange: handler, + } as const; + }, + [getValues, onChangeTransform], + ); + + const registerInput = useCallback( + <K extends keyof T>(name: K) => { + return register<K, InputElement<T[K]>, Partial<InputElement<T[K]>>>( + name, + e => { + resetFnMap.current[name] = () => (e.value = initialData[name]); + + return e.value; + }, + v => ({ + defaultValue: v, + }), + ); + }, + [register, initialData], + ); + const registerCheckbox = useCallback( + <K extends KeysWhereValue<T, boolean>>(name: K) => { + return register<K, CheckedElement, Partial<CheckedElement>>( + name, + e => { + resetFnMap.current[name] = () => (e.checked = initialData[name]); + + return e.checked as T[K]; + }, + v => ({ defaultChecked: v }), + ); + }, + [register, initialData], + ); + + const reset = useCallback(() => { + console.log(resetFnMap); + ref.current = { ...initialData }; + for (const key in resetFnMap.current) { + if (Object.prototype.hasOwnProperty.call(resetFnMap.current, key)) { + const fn = resetFnMap.current[key]; + if (fn) fn(); + } + } + resetFnMap.current = {}; + setState({ ...initialData }); + }, [initialData]); + + const onSubmit = useCallback( + <T>(e: React.FormEvent<T>) => { + e.preventDefault(); + e.stopPropagation(); + if (formSubmit) formSubmit(getValues(), reset); + }, + [formSubmit, getValues, reset], + ); + return { + reset, + register, + + registerInput, + registerCheckbox, + + formState: state, + setFormValue: setStateValue, + + onSubmit, + + getFormValues: getValues, + } as const; +}; + +export default useForm; diff --git a/frontend/src/hooks/useInitialState.ts b/frontend/src/hooks/useInitialState.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cb50b4cbcee0fda07db2ad6d664a7f2801a1c16 --- /dev/null +++ b/frontend/src/hooks/useInitialState.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from "react"; + +const useInitialState = <T>(prop: T) => { + const [value, setValue] = useState(prop); + useEffect(() => { + setValue(prop); + }, [prop]); + return [value, setValue] as const; +}; +export default useInitialState; diff --git a/frontend/src/hooks/useLoad.ts b/frontend/src/hooks/useLoad.ts new file mode 100644 index 0000000000000000000000000000000000000000..4317f75575886880cb7a43f85372520d32342847 --- /dev/null +++ b/frontend/src/hooks/useLoad.ts @@ -0,0 +1,124 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +// Modified version of https://github.com/umijs/hooks/blob/master/packages/hooks/src/useInViewport/index.ts +/* +MIT License + +Copyright (c) 2019 umijs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import { useRef, useLayoutEffect, useState, MutableRefObject } from "react"; + +import "intersection-observer"; +const radius = 500; + +type Arg = HTMLElement | (() => HTMLElement) | null; +type InViewport = boolean | undefined; + +function isInViewPort(el: HTMLElement): boolean { + if (!el) { + return false; + } + + const viewPortWidth = + window.innerWidth || + document.documentElement.clientWidth || + document.body.clientWidth; + const viewPortHeight = + window.innerHeight || + document.documentElement.clientHeight || + document.body.clientHeight; + const rect = el.getBoundingClientRect(); + + if (rect) { + const { top, bottom, left, right } = { + top: rect.top - radius, + bottom: rect.bottom + radius, + left: rect.left - radius, + right: rect.right + radius, + }; + return ( + bottom > 0 && top <= viewPortHeight && left <= viewPortWidth && right > 0 + ); + } + + return false; +} + +function useLoad<T extends HTMLElement = HTMLElement>(): [ + InViewport, + MutableRefObject<T>, +]; +function useLoad<T extends HTMLElement = HTMLElement>(arg: Arg): [InViewport]; +function useLoad<T extends HTMLElement = HTMLElement>( + ...args: [Arg] | [] +): [InViewport, MutableRefObject<T>?] { + const element = useRef<T>(); + const hasPassedInElement = args.length === 1; + const arg = useRef(args[0]); + [arg.current] = args; + const [inViewPort, setInViewport] = useState<InViewport>(() => { + const initDOM = + typeof arg.current === "function" ? arg.current() : arg.current; + + return isInViewPort(initDOM as HTMLElement); + }); + + useLayoutEffect(() => { + const passedInElement = + typeof arg.current === "function" ? arg.current() : arg.current; + + const targetElement = hasPassedInElement + ? passedInElement + : element.current; + + if (!targetElement) { + return () => {}; + } + + const observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + setInViewport(true); + } + } + }, + { rootMargin: `${radius}px ${radius}px ${radius}px ${radius}px` }, + ); + + observer.observe(targetElement); + + return () => { + observer.disconnect(); + }; + }, [ + element.current, + typeof arg.current === "function" ? undefined : arg.current, + ]); + + if (hasPassedInElement) { + return [inViewPort]; + } + + return [inViewPort, element as MutableRefObject<T>]; +} + +export default useLoad; diff --git a/frontend/src/hooks/useLongPress.ts b/frontend/src/hooks/useLongPress.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d71018e1f3c936dacccf1604a79c1dea5c883f7 --- /dev/null +++ b/frontend/src/hooks/useLongPress.ts @@ -0,0 +1,73 @@ +import { useRef, useCallback } from "react"; +const noUserSelect = ` + * { + user-select: none !important; + -webkit-user-select: none !important; + -webkit-touch-callout: none !important; + } +`; +const createStyle = () => { + const node = document.createElement("style"); + node.innerHTML = noUserSelect; + document.head.appendChild(node); + return node; +}; +const removeStyle = (node: HTMLStyleElement | undefined) => { + if (node && document.head === node.parentElement) + document.head.removeChild(node); +}; +type Point = [number, number]; +const useLongPress = <T>( + onHold: () => void, + onClick: (e: React.MouseEvent<T>) => void, + longPressTime: number = 500, + longPressDistanceSq: number = 20, +) => { + const timer = useRef<number | undefined>(); + const pos = useRef<Point>([0, 0]); + const style = useRef<HTMLStyleElement | undefined>(); + const handler = useCallback(() => { + timer.current = undefined; + onHold(); + }, [timer, onHold]); + + const onPointerDown = useCallback( + (e: React.PointerEvent<T>) => { + e.preventDefault(); + style.current = createStyle(); + pos.current = [e.clientX, e.clientY]; + const timeoutId = window.setTimeout(handler, longPressTime); + timer.current = timeoutId; + }, + [handler, longPressTime, pos, timer], + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent<T>) => { + removeStyle(style.current); + if (timer.current) { + window.clearTimeout(timer.current); + timer.current = undefined; + onClick(e); + } + }, + [timer, onClick], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent<T>) => { + if (timer) { + const [x, y] = pos.current; + const d = (e.clientX - x) ** 2 + (e.clientY - y) ** 2; + if (d > longPressDistanceSq) { + window.clearTimeout(timer.current); + timer.current = undefined; + } + } + }, + [timer, pos, longPressDistanceSq], + ); + return { onPointerDown, onPointerUp, onPointerMove } as const; +}; + +export default useLongPress; diff --git a/frontend/src/hooks/useSet.ts b/frontend/src/hooks/useSet.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7fb658752283c37ae68ae33600152b0faad8afe --- /dev/null +++ b/frontend/src/hooks/useSet.ts @@ -0,0 +1,27 @@ +import { useCallback, useState } from "react"; + +const useSet = <T>(defaultValue?: Set<T>) => { + const [value, setValue] = useState(() => defaultValue || new Set<T>()); + + const addEntries = useCallback((...entries: T[]) => { + setValue(prevSelected => { + const copy = new Set(prevSelected); + for (const entry of entries) copy.add(entry); + if (copy.size === prevSelected.size) return prevSelected; + return copy; + }); + }, []); + const deleteEntries = useCallback((...entries: T[]) => { + setValue(prevSelected => { + const copy = new Set(prevSelected); + for (const entry of entries) copy.delete(entry); + if (copy.size === prevSelected.size) return prevSelected; + return copy; + }); + }, []); + const setEntries = useCallback((...entries: T[]) => { + setValue(new Set(entries)); + }, []); + return [value, addEntries, deleteEntries, setEntries] as const; +}; +export default useSet; diff --git a/frontend/src/hooks/useTitle.ts b/frontend/src/hooks/useTitle.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e4b239945f23d8edb248c0c4de2b994e07f3c13 --- /dev/null +++ b/frontend/src/hooks/useTitle.ts @@ -0,0 +1,9 @@ +import { useEffect } from "react"; + +const useTitle = (title: string) => { + useEffect(() => { + document.title = title; + }, [title]); +}; + +export default useTitle; diff --git a/frontend/src/hooks/useToggle.ts b/frontend/src/hooks/useToggle.ts new file mode 100644 index 0000000000000000000000000000000000000000..d24114fa9aaf1e32f482ec5ca491cc8c4e24dc2f --- /dev/null +++ b/frontend/src/hooks/useToggle.ts @@ -0,0 +1,8 @@ +import { useCallback, useState } from "react"; + +const useToggle = (initialValue: boolean = false) => { + const [value, setValue] = useState(initialValue); + const toggle = useCallback(() => setValue(v => !v), []); + return [value, toggle, setValue] as const; +}; +export default useToggle; diff --git a/frontend/src/hooks/useWasInViewport.ts b/frontend/src/hooks/useWasInViewport.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f0490ab771e446ed0d83c35f055e8cdd414f4f0 --- /dev/null +++ b/frontend/src/hooks/useWasInViewport.ts @@ -0,0 +1,28 @@ +import { useRef, useState } from "react"; + +const useWasInViewport = <T extends HTMLElement>() => { + const observer: React.MutableRefObject< + IntersectionObserver | undefined + > = useRef<IntersectionObserver | undefined>(); + const [wasInView, setWasInView] = useState(false); + + const ref: React.Ref<T> = (element: T | null) => { + const oldObserver = observer.current; + if (oldObserver) oldObserver.disconnect(); + if (element === null) return; + const newObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + if (entry.target === element && entry.isIntersecting) { + setWasInView(true); + } + } + }); + newObserver.observe(element); + observer.current = newObserver; + newObserver.observe(element); + }; + + return [wasInView, ref] as const; +}; + +export default useWasInViewport; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 86913af8c1ffc15d22e222435e58a94c117eaeeb..6ffeccbcfbb4fb91e8885050d171732df1e714cf 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,11 +1,7 @@ import * as React from "react"; import * as ReactDOM from "react-dom"; import App from "./app"; -//import registerServiceWorker from "./register-service-worker"; import { BrowserRouter } from "react-router-dom"; -import { css } from "glamor"; - -css.global("body", { margin: 0 }); ReactDOM.render( <BrowserRouter> @@ -13,4 +9,3 @@ ReactDOM.render( </BrowserRouter>, document.getElementById("root") as HTMLElement, ); -//registerServiceWorker(); diff --git a/frontend/src/input-utils.tsx b/frontend/src/input-utils.tsx deleted file mode 100644 index fdbd7175fdc603fc522b8bf1b1b6c3e181210ca1..0000000000000000000000000000000000000000 --- a/frontend/src/input-utils.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import * as React from "react"; - -export function listenForKey<T>( - callback: Function, - key: string, - ctrl: boolean, -) { - return (ev: React.KeyboardEvent<T>) => { - if (ev.key === key && ev.ctrlKey === ctrl) { - callback(); - } - }; -} - -export function listenEnter<T>(callback: Function, ctrl?: boolean) { - return listenForKey<T>(callback, "Enter", ctrl || false); -} diff --git a/frontend/src/interfaces.ts b/frontend/src/interfaces.ts index 98627f51d77c86f6a2931ecb30d06d7d9bb1fb7d..fcf4dbf6b7d0e081941fbae660ca45682a67a97e 100644 --- a/frontend/src/interfaces.ts +++ b/frontend/src/interfaces.ts @@ -235,3 +235,20 @@ export interface FAQEntry { answer: string; order: number; } + +export interface CutVersions { + [oid: string]: number; +} +export interface ServerCutResponse { + [pageNumber: string]: ServerCutPosition[]; +} + +export enum EditMode { + None, + Add, + Move, +} +export type EditState = + | { mode: EditMode.None } + | { mode: EditMode.Add; snap: boolean } + | { mode: EditMode.Move; cut: string; snap: boolean }; diff --git a/frontend/src/pages/category-page.tsx b/frontend/src/pages/category-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b2d355210cee24dc086b966a8ea20767d27ed2a --- /dev/null +++ b/frontend/src/pages/category-page.tsx @@ -0,0 +1,202 @@ +import { useRequest } from "@umijs/hooks"; +import { + Alert, + Badge, + Breadcrumb, + Col, + Container, + ListGroup, + ListGroupItem, + Row, + Spinner, +} from "@vseth/components"; +import { BreadcrumbItem } from "@vseth/components/dist/components/Breadcrumb/Breadcrumb"; +import React, { useCallback, useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { loadCategoryMetaData, loadMetaCategories } from "../api/hooks"; +import { UserContext, useUser } from "../auth"; +import CategoryMetaDataEditor from "../components/category-metadata-editor"; +import ExamList from "../components/exam-list"; +import IconButton from "../components/icon-button"; +import LoadingOverlay from "../components/loading-overlay"; +import { CategoryMetaData } from "../interfaces"; +import { getMetaCategoriesForCategory } from "../utils/category-utils"; +import useTitle from "../hooks/useTitle"; + +interface CategoryPageContentProps { + onMetaDataChange: (newMetaData: CategoryMetaData) => void; + metaData: CategoryMetaData; +} +const CategoryPageContent: React.FC<CategoryPageContentProps> = ({ + onMetaDataChange, + metaData, +}) => { + const { data, loading } = useRequest(loadMetaCategories, { + cacheKey: "meta-categories", + }); + const offeredIn = useMemo( + () => + data ? getMetaCategoriesForCategory(data, metaData.slug) : undefined, + [data, metaData], + ); + const [editing, setEditing] = useState(false); + const toggle = useCallback(() => setEditing(a => !a), []); + const user = useUser()!; + return ( + <> + <Breadcrumb> + <BreadcrumbItem> + <Link to="/">Home</Link> + </BreadcrumbItem> + <BreadcrumbItem>{metaData.displayname}</BreadcrumbItem> + </Breadcrumb> + {editing ? ( + offeredIn && ( + <CategoryMetaDataEditor + onMetaDataChange={onMetaDataChange} + isOpen={editing} + toggle={toggle} + currentMetaData={metaData} + offeredIn={offeredIn.flatMap(b => + b.meta2.map(d => [b.displayname, d.displayname] as const), + )} + /> + ) + ) : ( + <> + {user.isCategoryAdmin && ( + <IconButton + tooltip="Edit category metadata" + close + icon="EDIT" + onClick={() => setEditing(true)} + /> + )} + <h1>{metaData.displayname}</h1> + <Row> + <Col lg="6"> + <ListGroup className="m-2"> + {metaData.semester && ( + <ListGroupItem> + Semester: <Badge>{metaData.semester}</Badge> + </ListGroupItem> + )} + {metaData.form && ( + <ListGroupItem> + Form: <Badge>{metaData.form}</Badge> + </ListGroupItem> + )} + {(offeredIn === undefined || offeredIn.length > 0) && ( + <ListGroupItem> + Offered in: + <div> + {loading ? ( + <Spinner /> + ) : ( + <ul> + {offeredIn?.map(meta1 => + meta1.meta2.map(meta2 => ( + <li key={meta1.displayname + meta2.displayname}> + {meta2.displayname} in {meta1.displayname} + </li> + )), + )} + </ul> + )} + </div> + </ListGroupItem> + )} + {metaData.more_exams_link && ( + <ListGroupItem> + <a + href={metaData.more_exams_link} + target="_blank" + rel="noopener noreferrer" + > + Additional Exams + </a> + </ListGroupItem> + )} + {metaData.remark && ( + <ListGroupItem>Remark: {metaData.remark}</ListGroupItem> + )} + {metaData.experts.includes(user.username) && ( + <ListGroupItem> + You are an expert for this category. You can endorse correct + answers. + </ListGroupItem> + )} + {metaData.has_payments && ( + <ListGroupItem> + You have to pay a deposit of 20 CHF in the VIS bureau in + order to see oral exams. + <br /> + After submitting a report of your own oral exam you can get + your deposit back. + </ListGroupItem> + )} + {metaData.catadmin && ( + <ListGroupItem> + You can edit exams in this category. Please do so + responsibly. + </ListGroupItem> + )} + </ListGroup> + </Col> + <ExamList metaData={metaData} /> + <Col lg={6}> + {metaData.attachments.length > 0 && ( + <> + <h2>Attachments</h2> + <ListGroup flush> + {metaData.attachments.map(att => ( + <a + href={`/api/filestore/get/${att.filename}/`} + target="_blank" + rel="noopener noreferrer" + key={att.filename} + > + <ListGroupItem>{att.displayname}</ListGroupItem> + </a> + ))} + </ListGroup> + </> + )} + </Col> + </Row> + </> + )} + </> + ); +}; + +const CategoryPage: React.FC<{}> = () => { + const { slug } = useParams() as { slug: string }; + const { data, loading, error, mutate } = useRequest( + () => loadCategoryMetaData(slug), + { cacheKey: `category-${slug}` }, + ); + useTitle(`${data?.displayname ?? slug} - VIS Community Solutions`); + const user = useUser(); + return ( + <Container> + {error && <Alert color="danger">{error.message}</Alert>} + {data === undefined && <LoadingOverlay loading={loading} />} + {data && ( + <UserContext.Provider + value={ + user + ? { + ...user, + isCategoryAdmin: user.isCategoryAdmin || data.catadmin, + } + : undefined + } + > + <CategoryPageContent metaData={data} onMetaDataChange={mutate} /> + </UserContext.Provider> + )} + </Container> + ); +}; +export default CategoryPage; diff --git a/frontend/src/pages/category.tsx b/frontend/src/pages/category.tsx deleted file mode 100644 index 9a4696811278a901058c326726a138322f586f69..0000000000000000000000000000000000000000 --- a/frontend/src/pages/category.tsx +++ /dev/null @@ -1,1046 +0,0 @@ -import * as React from "react"; -import { - Attachment, - CategoryExam, - CategoryMetaData, - MetaCategory, -} from "../interfaces"; -import { css } from "glamor"; -import { fetchGet, fetchPost, getCookie } from "../fetch-utils"; -import { Link, Redirect } from "react-router-dom"; -import { - filterExams, - filterMatches, - getMetaCategoriesForCategory, -} from "../category-utils"; -import AutocompleteInput from "../components/autocomplete-input"; -import colors from "../colors"; -import GlobalConsts from "../globalconsts"; -import moment from "moment"; -import Colors from "../colors"; -import { listenEnter } from "../input-utils"; -import Attachments from "../components/attachments"; -import TextLink from "../components/text-link"; -import { KeysWhereValue } from "../ts-utils"; - -const styles = { - wrapper: css({ - maxWidth: "900px", - margin: "auto", - }), - metadata: css({ - marginBottom: "4px", - }), - offeredIn: css({ - marginTop: "4px", - marginBottom: "4px", - }), - metdataWrapper: css({ - padding: "10px", - marginBottom: "20px", - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - }), - unviewableExam: css({ - color: colors.inactiveElement, - }), - filterInput: css({ - width: "100%", - marginTop: "20px", - marginBottom: "20px", - "& input": { - width: "50%", - "@media (max-width: 799px)": { - width: "70%", - }, - "@media (max-width: 599px)": { - width: "90%", - }, - }, - }), - examsTable: css({ - width: "100%", - marginBottom: "20px", - }), - selectionColumn: css({ - width: "50px", - textAlign: "center", - }), - selectionButtons: css({ - display: "flex", - }), - selectionButton: css({ - cursor: "pointer", - marginLeft: "5px", - }), - selectionImg: css({ - height: "20px", - }), -}; - -interface Props { - isAdmin?: boolean; - username: string; - categorySlug: string; -} - -interface State { - category?: CategoryMetaData; - exams: CategoryExam[]; - examTypes: string[]; - metaCategories: MetaCategory[]; - filter: string; - newMeta1: string; - newMeta2: string; - newAdminName: string; - newExpertName: string; - currentMetaData: CategoryMetaData; - editingMetaData: boolean; - gotoExam?: CategoryExam; - redirectBack: boolean; - error?: string; - selectedExams: Set<string>; -} - -export default class Category extends React.Component<Props, State> { - state: State = { - exams: [], - examTypes: [], - metaCategories: [], - filter: "", - newMeta1: "", - newMeta2: "", - newAdminName: "", - newExpertName: "", - currentMetaData: { - displayname: "", - slug: "", - admins: [], - experts: [], - semester: "", - form: "", - permission: "", - remark: "", - has_payments: false, - catadmin: false, - more_exams_link: "", - examcountpublic: 0, - examcountanswered: 0, - answerprogress: 0, - attachments: [], - }, - editingMetaData: false, - redirectBack: false, - selectedExams: new Set<string>(), - }; - - componentDidMount() { - this.loadCategory(); - this.loadExams(); - this.loadMetaCategories(); - document.title = this.props.categorySlug + " - VIS Community Solutions"; - } - - collectExamTypes = (exams: CategoryExam[]) => { - const types = exams.map(exam => exam.examtype).filter(examtype => examtype); - types.push("Exams"); - return types - .filter((value, index, self) => self.indexOf(value) === index) - .sort(); - }; - - loadExams = () => { - fetchGet("/api/category/listexams/" + this.props.categorySlug + "/") - .then(res => { - this.setState({ - exams: res.value, - examTypes: this.collectExamTypes(res.value), - }); - }) - .catch(() => undefined); - }; - - loadCategory = () => { - fetchGet("/api/category/metadata/" + this.props.categorySlug + "/") - .then(res => { - this.setState({ - category: res.value, - }); - document.title = res.value.displayname + " - VIS Community Solutions"; - }) - .catch(() => undefined); - }; - - loadMetaCategories = () => { - fetchGet("/api/category/listmetacategories/") - .then(res => { - this.setState({ - metaCategories: res.value, - }); - }) - .catch(() => undefined); - }; - - toggleEditingMetadata = () => { - this.setState(prevState => ({ - editingMetaData: !prevState.editingMetaData, - })); - if (this.state.category) { - this.setState({ - currentMetaData: { ...this.state.category }, - }); - } - }; - - saveEdit = () => { - if (!this.state.category) { - return; - } - const data = { ...this.state.currentMetaData }; - fetchPost( - "/api/category/setmetadata/" + this.state.category.slug + "/", - data, - ) - .then(() => { - this.setState({ - editingMetaData: false, - }); - this.loadCategory(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - cancelEdit = () => { - this.setState({ - editingMetaData: false, - }); - }; - - filterChanged = (ev: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ - filter: ev.target.value, - }); - }; - - openFirstExam = () => { - const filtered = filterExams(this.state.exams, this.state.filter); - if (filtered.length > 0) { - this.gotoExam(filtered[0]); - } - }; - - gotoExam = (cat: CategoryExam) => { - this.setState({ - gotoExam: cat, - }); - }; - - valueChanged = ( - key: KeysWhereValue<CategoryMetaData, string>, - event: React.ChangeEvent<HTMLInputElement>, - ) => { - const newVal = event.target.value; - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - [key]: newVal, - }, - })); - }; - - checkboxValueChanged = ( - key: KeysWhereValue<CategoryMetaData, boolean>, - event: React.ChangeEvent<HTMLInputElement>, - ) => { - const newVal = event.target.checked; - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - [key]: newVal, - }, - })); - }; - - // "whether current user wants to download this" is not a metadata of the category, so different fun - selectedExamsCheckboxValueChanged = ( - key: string, - event: React.ChangeEvent<HTMLInputElement>, - ) => { - const newVal = event.target.checked; - if (newVal) { - this.setState(prevState => { - const newSelectedExams = new Set(prevState.selectedExams); - newSelectedExams.add(key); - return { - selectedExams: newSelectedExams, - }; - }); - } else { - this.setState(prevState => { - const newSelectedExams = new Set(prevState.selectedExams); - newSelectedExams.delete(key); - return { - selectedExams: newSelectedExams, - }; - }); - } - }; - - selectAllExams = (examType: string) => { - this.setState(prevState => { - const newSelectedExams = new Set(prevState.selectedExams); - for (const exam of prevState.exams) { - const currExamtype = exam.examtype ? exam.examtype : "Exams"; - if (currExamtype === examType && exam.canView) - newSelectedExams.add(exam.filename); - } - return { - selectedExams: newSelectedExams, - }; - }); - }; - - unselectAllExams = (examType: string) => { - this.setState(prevState => { - const newSelectedExams = new Set(prevState.selectedExams); - for (const exam of prevState.exams) { - const currExamtype = exam.examtype ? exam.examtype : "Exams"; - if (currExamtype === examType && exam.canView) - newSelectedExams.delete(exam.filename); - } - return { - selectedExams: newSelectedExams, - }; - }); - }; - - // https://stackoverflow.com/questions/17793183/how-to-replace-window-open-with-a-post - dlSelectedExams = () => { - if (!this.state.category) return; - const form = document.createElement("form"); - form.action = "/api/exam/zipexport/"; - form.method = "POST"; - form.target = "_blank"; - this.state.selectedExams.forEach(filename => { - const input = document.createElement("input"); - input.name = "filenames"; - input.value = filename; - form.appendChild(input); - }); - const csrf = document.createElement("input"); - csrf.name = "csrfmiddlewaretoken"; - csrf.value = getCookie("csrftoken") || ""; - form.appendChild(csrf); - form.style.display = "none"; - document.body.appendChild(form); - form.submit(); - document.body.removeChild(form); - }; - - addToSet = (key: string, value: string) => { - return fetchPost( - "/api/category/addusertoset/" + this.props.categorySlug + "/", - { - key: key, - user: value, - }, - ) - .then(() => { - this.loadCategory(); - }) - .catch(err => { - this.setState({ - error: "User might not exist (" + err.toString() + ")", - }); - }); - }; - - pullSet = (key: string, value: string) => { - return fetchPost( - "/api/category/removeuserfromset/" + this.props.categorySlug + "/", - { - key: key, - user: value, - }, - ) - .then(() => { - this.loadCategory(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - addAdmin = () => { - if (!this.state.newAdminName) { - return; - } - this.addToSet("admins", this.state.newAdminName).then(() => { - this.setState({ - newAdminName: "", - }); - }); - }; - - removeAdmin = (username: string) => { - this.pullSet("admins", username); - }; - - addExpert = () => { - if (!this.state.newExpertName) { - return; - } - this.addToSet("experts", this.state.newExpertName).then(() => { - this.setState({ - newExpertName: "", - }); - }); - }; - - removeExpert = (username: string) => { - this.pullSet("experts", username); - }; - - addMetaCategory = () => { - if (!this.state.newMeta1 || !this.state.newMeta2 || !this.state.category) { - return; - } - fetchPost("/api/category/addmetacategory/", { - meta1: this.state.newMeta1, - meta2: this.state.newMeta2, - category: this.state.category.slug, - }) - .then(() => { - this.setState({ - newMeta1: "", - newMeta2: "", - }); - this.loadMetaCategories(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - removeMetaCategory = (meta1: string, meta2: string) => { - if (!this.state.category) { - return; - } - fetchPost("/api/category/removemetacategory/", { - meta1: meta1, - meta2: meta2, - category: this.state.category.slug, - }) - .then(() => { - this.loadMetaCategories(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - removeCategory = () => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove category?"); - if (confirmation) { - fetchPost("/api/category/remove/", { - slug: this.props.categorySlug, - }) - .then(() => { - this.setState({ - redirectBack: true, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } - }; - - removeExam = (exam: CategoryExam) => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm( - "Remove exam? This will remove all answers and can not be undone!", - ); - if (confirmation) { - const confirmation2 = prompt( - "Please enter '" + exam.displayname + "' to delete the exam.", - ); - if (confirmation2 === exam.displayname) { - fetchPost(`/api/exam/remove/exam/${exam.filename}/`, {}) - .then(() => { - this.loadExams(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } else { - alert( - "Name did not match. If you really want to delete the exam, try again.", - ); - } - } - }; - - flatArray = (arr: string[][]) => { - const res: string[] = []; - arr.forEach(a => { - res.push.apply(res, a); - }); - return res; - }; - - hasValidClaim = (exam: CategoryExam) => { - if (exam.import_claim !== null && exam.import_claim_time !== null) { - if ( - moment().diff( - moment(exam.import_claim_time, GlobalConsts.momentParseString), - ) < - 4 * 60 * 60 * 1000 - ) { - return true; - } - } - return false; - }; - - claimExam = (exam: CategoryExam, claim: boolean) => { - fetchPost(`/api/exam/claimexam/${exam.filename}/`, { - claim: claim, - }) - .then(() => { - this.loadExams(); - if (claim) { - window.open("/exams/" + exam.filename); - } - }) - .catch(err => { - this.setState({ - error: err, - }); - this.loadExams(); - }); - }; - - addAttachment = (att: Attachment) => { - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - attachments: [...prevState.currentMetaData.attachments, att], - }, - })); - this.loadCategory(); - }; - - removeAttachment = (att: Attachment) => { - this.setState(prevState => ({ - currentMetaData: { - ...prevState.currentMetaData, - attachments: prevState.currentMetaData.attachments.filter( - attachment => attachment !== att, - ), - }, - })); - this.loadCategory(); - }; - - render() { - if (this.state.redirectBack) { - return <Redirect to="/" />; - } - if (this.state.gotoExam) { - return ( - <Redirect to={"/exams/" + this.state.gotoExam.filename} push={true} /> - ); - } - if (!this.state.category) { - return <div>Loading...</div>; - } - const catAdmin = this.props.isAdmin || this.state.category.catadmin; - const cat = this.state.category; - const offeredIn = getMetaCategoriesForCategory( - this.state.metaCategories, - cat.displayname, - ); - const viewableExams = this.state.exams - .filter(exam => exam.public || catAdmin) - .filter(exam => filterMatches(this.state.filter, exam.displayname)); - const attachments = - this.state.currentMetaData.attachments.length > 0 - ? this.state.currentMetaData.attachments - : this.state.category.attachments; - return ( - <div {...styles.wrapper}> - <h1>{cat.displayname}</h1> - <div> - {this.state.category.semester && ( - <div {...styles.metadata}> - Semester: {this.state.category.semester} - </div> - )} - {this.state.category.form && ( - <div {...styles.metadata}>Form: {this.state.category.form}</div> - )} - {offeredIn.length > 0 && ( - <div {...styles.metadata}> - Offered in: - <ul {...styles.offeredIn}> - {offeredIn.map(meta1 => - meta1.meta2.map(meta2 => ( - <li key={meta1.displayname + meta2.displayname}> - {meta2.displayname} in {meta1.displayname} - </li> - )), - )} - </ul> - </div> - )} - {this.state.category.remark && ( - <div {...styles.metadata}>Remark: {this.state.category.remark}</div> - )} - {this.state.category.more_exams_link && ( - <div {...styles.metadata}> - <a - href={this.state.category.more_exams_link} - target="_blank" - rel="noopener noreferrer" - > - Additional Exams - </a> - </div> - )} - {this.state.category.has_payments && ( - <div {...styles.metadata}> - You have to pay a deposit of 20 CHF in the VIS bureau in order to - see oral exams. - <br /> - After submitting a report of your own oral exam you can get your - deposit back. - </div> - )} - {catAdmin && ( - <div {...styles.metadata}> - You can edit exams in this category. Please do so responsibly. - </div> - )} - {this.state.currentMetaData.experts.indexOf(this.props.username) !== - -1 && ( - <div {...styles.metadata}> - You are an expert for this category. You can endorse correct - answers. - </div> - )} - {this.state.error && ( - <div {...styles.metadata}>{this.state.error}</div> - )} - {this.props.isAdmin && ( - <div {...styles.metadata}> - <button onClick={this.toggleEditingMetadata}> - Edit Category - </button> - </div> - )} - {this.state.editingMetaData && ( - <div {...styles.metdataWrapper}> - <h2>Meta Data</h2> - <div> - <AutocompleteInput - name="semester" - placeholder="semester" - value={this.state.currentMetaData.semester} - onChange={ev => this.valueChanged("semester", ev)} - autocomplete={["HS", "FS"]} - /> - <AutocompleteInput - name="form" - placeholder="form" - value={this.state.currentMetaData.form} - onChange={ev => this.valueChanged("form", ev)} - autocomplete={["written", "oral"]} - /> - </div> - <div> - <input - type="text" - placeholder="remark" - value={this.state.currentMetaData.remark} - onChange={ev => this.valueChanged("remark", ev)} - /> - <AutocompleteInput - name="permission" - placeholder="permission" - value={this.state.currentMetaData.permission} - onChange={ev => this.valueChanged("permission", ev)} - autocomplete={["public", "intern", "hidden", "none"]} - /> - </div> - <div> - <input - type="text" - placeholder="more exams link" - value={this.state.currentMetaData.more_exams_link} - onChange={ev => this.valueChanged("more_exams_link", ev)} - /> - </div> - <div> - <label> - <input - type="checkbox" - checked={this.state.currentMetaData.has_payments} - onChange={ev => - this.checkboxValueChanged("has_payments", ev) - } - /> - Has Payments - </label> - </div> - <div> - <button onClick={this.saveEdit}>Save</button> - <button onClick={this.cancelEdit}>Cancel</button> - </div> - <div> - <h2>Attachments</h2> - <Attachments - attachments={this.state.currentMetaData.attachments} - additionalArgs={{ category: this.props.categorySlug }} - onAddAttachment={this.addAttachment} - onRemoveAttachment={this.removeAttachment} - /> - </div> - <div> - <h2>Offered In</h2> - <ul> - {offeredIn.map(meta1 => - meta1.meta2.map(meta2 => ( - <li key={meta1.displayname + meta2.displayname}> - {meta2.displayname} in {meta1.displayname}{" "} - <button - onClick={() => - this.removeMetaCategory( - meta1.displayname, - meta2.displayname, - ) - } - > - X - </button> - </li> - )), - )} - </ul> - <AutocompleteInput - name="meta" - onChange={ev => this.setState({ newMeta1: ev.target.value })} - value={this.state.newMeta1} - placeholder="main category" - onKeyPress={listenEnter(this.addMetaCategory)} - autocomplete={this.state.metaCategories.map( - meta1 => meta1.displayname, - )} - /> - <AutocompleteInput - name="submeta" - onChange={ev => this.setState({ newMeta2: ev.target.value })} - value={this.state.newMeta2} - placeholder="sub category" - onKeyPress={listenEnter(this.addMetaCategory)} - autocomplete={this.flatArray( - this.state.metaCategories - .filter( - meta1 => meta1.displayname === this.state.newMeta1, - ) - .map(meta1 => - meta1.meta2.map(meta2 => meta2.displayname), - ), - )} - /> - <button - onClick={this.addMetaCategory} - disabled={ - this.state.newMeta1.length === 0 || - this.state.newMeta2.length === 0 - } - > - Add Offered In - </button> - </div> - <div> - <h2>Admins</h2> - <ul> - {this.state.category.admins.map(admin => ( - <li key={admin}> - {admin}{" "} - <button onClick={() => this.removeAdmin(admin)}>X</button> - </li> - ))} - </ul> - <input - type="text" - value={this.state.newAdminName} - onChange={ev => - this.setState({ newAdminName: ev.target.value }) - } - placeholder="new admin" - onKeyPress={listenEnter(this.addAdmin)} - /> - <button - onClick={this.addAdmin} - disabled={this.state.newAdminName.length === 0} - > - Add Admin - </button> - </div> - <div> - <h2>Experts</h2> - <ul> - {this.state.category.experts.map(expert => ( - <li key={expert}> - {expert}{" "} - <button onClick={() => this.removeExpert(expert)}> - X - </button> - </li> - ))} - </ul> - <input - type="text" - value={this.state.newExpertName} - onChange={ev => - this.setState({ newExpertName: ev.target.value }) - } - placeholder="new expert" - onKeyPress={listenEnter(this.addExpert)} - /> - <button - onClick={this.addExpert} - disabled={this.state.newExpertName.length === 0} - > - Add Expert - </button> - </div> - <div> - <h2>Remove Category</h2> - <button onClick={this.removeCategory}>Remove Category</button> - </div> - </div> - )} - </div> - - <div> - <button - onClick={this.dlSelectedExams} - disabled={this.state.selectedExams.size === 0} - > - Download selected exams - </button> - </div> - - <div {...styles.filterInput}> - <input - type="text" - onChange={this.filterChanged} - value={this.state.filter} - placeholder="Filter..." - autoFocus={true} - onKeyPress={listenEnter(this.openFirstExam)} - /> - </div> - {this.state.examTypes - .filter( - examType => - viewableExams.filter( - exam => (exam.examtype || "Exams") === examType, - ).length > 0, - ) - .map(examType => ( - <div key={examType}> - <h2> - <TextLink to={"#" + examType} id={examType}> - {examType} - </TextLink> - </h2> - <table {...styles.examsTable}> - <thead> - <tr> - <th {...styles.selectionColumn}> - <div {...styles.selectionButtons}> - <div - {...styles.selectionButton} - onClick={ev => this.selectAllExams(examType)} - > - <img - {...styles.selectionImg} - src="/static/select_all.svg" - title="Select All" - alt="Select All" - /> - </div> - <div - {...styles.selectionButton} - onClick={ev => this.unselectAllExams(examType)} - > - <img - {...styles.selectionImg} - src="/static/deselect_all.svg" - title="Deselect All" - alt="Deselect All" - /> - </div> - </div> - </th> - <th>Name</th> - <th>Remark</th> - <th>Answers</th> - {catAdmin && <th>Public</th>} - {catAdmin && <th>Import State</th>} - {catAdmin && <th>Claim</th>} - {this.props.isAdmin && <th>Remove</th>} - </tr> - </thead> - <tbody> - {viewableExams - .filter(exam => (exam.examtype || "Exams") === examType) - .map(exam => ( - <tr key={exam.filename}> - <td {...styles.selectionColumn}> - <input - type="checkbox" - checked={this.state.selectedExams.has( - exam.filename, - )} - onChange={ev => - this.selectedExamsCheckboxValueChanged( - exam.filename, - ev, - ) - } - disabled={!exam.canView} - /> - </td> - <td> - {(exam.canView && ( - <Link to={"/exams/" + exam.filename}> - {exam.displayname} - </Link> - )) || ( - <span {...styles.unviewableExam}> - {exam.displayname} - </span> - )} - </td> - <td> - {exam.remark} - {exam.is_printonly ? ( - <span title="This exam can only be printed. We can not provide this exam online."> - {" "} - (Print Only) - </span> - ) : ( - undefined - )} - </td> - <td> - <span - title={`There are ${exam.count_cuts} questions, of which ${exam.count_answered} have at least one solution.`} - > - {exam.count_answered} / {exam.count_cuts} - </span> - {exam.has_solution ? ( - <span title="Has an official solution."> - {" "} - (Solution) - </span> - ) : ( - undefined - )} - </td> - {catAdmin && ( - <td> - {exam.public ? "Public" : "Hidden"} - {exam.needs_payment ? " (oral)" : ""} - </td> - )} - {catAdmin && ( - <td> - {exam.finished_cuts - ? exam.finished_wiki_transfer - ? "All done" - : "Needs Wiki Import" - : "Needs Cuts"} - </td> - )} - {catAdmin && ( - <td> - {!exam.finished_cuts || - !exam.finished_wiki_transfer ? ( - this.hasValidClaim(exam) ? ( - exam.import_claim === this.props.username ? ( - <button - onClick={() => this.claimExam(exam, false)} - > - Release Claim - </button> - ) : ( - <span> - Claimed by {exam.import_claim_displayname} - </span> - ) - ) : ( - <button - onClick={() => this.claimExam(exam, true)} - > - Claim Exam - </button> - ) - ) : ( - <span>-</span> - )} - </td> - )} - {this.props.isAdmin && ( - <td> - <button onClick={ev => this.removeExam(exam)}> - X - </button> - </td> - )} - </tr> - ))} - </tbody> - </table> - </div> - ))} - {attachments.length > 0 && ( - <div> - <h2>Attachments</h2> - {attachments.map(att => ( - <div key={att.filename}> - <a - href={"/api/filestore/get/" + att.filename + "/"} - target="_blank" - rel="noopener noreferrer" - > - {att.displayname} - </a> - </div> - ))} - </div> - )} - </div> - ); - } -} diff --git a/frontend/src/pages/exam-page.tsx b/frontend/src/pages/exam-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa170226c63ead541dd32611a5f66186640795af --- /dev/null +++ b/frontend/src/pages/exam-page.tsx @@ -0,0 +1,453 @@ +import { useLocalStorageState, useRequest, useSize } from "@umijs/hooks"; +import { + Alert, + Breadcrumb, + BreadcrumbItem, + Button, + Card, + CardBody, + Col, + Container, + Row, + Spinner, +} from "@vseth/components"; +import React, { useCallback, useMemo, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { loadSections } from "../api/exam-loader"; +import { fetchPost } from "../api/fetch-utils"; +import { loadCuts, loadExamMetaData, loadSplitRenderer } from "../api/hooks"; +import { UserContext, useUser } from "../auth"; +import Exam from "../components/exam"; +import ExamMetadataEditor from "../components/exam-metadata-editor"; +import ExamPanel from "../components/exam-panel"; +import IconButton from "../components/icon-button"; +import PrintExam from "../components/print-exam"; +import useSet from "../hooks/useSet"; +import useToggle from "../hooks/useToggle"; +import { + EditMode, + EditState, + ExamMetaData, + PdfSection, + Section, + ServerCutResponse, + SectionKind, +} from "../interfaces"; +import PDF from "../pdf/pdf-renderer"; +import ContentContainer from "../components/secondary-container"; +import useTitle from "../hooks/useTitle"; +import { TOCNode, TOC } from "../components/table-of-contents"; + +const addCut = async ( + filename: string, + pageNum: number, + relHeight: number, + hidden = false, +) => { + await fetchPost(`/api/exam/addcut/${filename}/`, { + pageNum, + relHeight, + name: "", + hidden, + }); +}; +const moveCut = async ( + filename: string, + cut: string, + pageNum: number, + relHeight: number, +) => { + await fetchPost(`/api/exam/editcut/${cut}/`, { pageNum, relHeight }); +}; +const updateCutName = async (cut: string, name: string) => { + await fetchPost(`/api/exam/editcut/${cut}/`, { name }); +}; +const updateCutHidden = async (cut: string, hidden: boolean) => { + console.log("updateCutHidden", cut, hidden); + await fetchPost(`/api/exam/editcut/${cut}/`, { hidden }); +}; + +interface ExamPageContentProps { + metaData: ExamMetaData; + sections?: Section[]; + renderer?: PDF; + reloadCuts: () => void; + mutateCuts: (mutation: (old: ServerCutResponse) => ServerCutResponse) => void; + toggleEditing: () => void; +} +const ExamPageContent: React.FC<ExamPageContentProps> = ({ + metaData, + sections, + renderer, + reloadCuts, + mutateCuts, + toggleEditing, +}) => { + const user = useUser()!; + const { run: runAddCut } = useRequest(addCut, { + manual: true, + onSuccess: reloadCuts, + }); + const { run: runMoveCut } = useRequest(moveCut, { + manual: true, + onSuccess: () => { + reloadCuts(); + setEditState({ mode: EditMode.None }); + }, + }); + const { run: runUpdateCutName } = useRequest(updateCutName, { + manual: true, + onSuccess: (_data, [oid, newName]) => { + mutateCuts(oldCuts => + Object.keys(oldCuts).reduce((result, key) => { + result[key] = oldCuts[key].map(cutPosition => + cutPosition.oid === oid + ? { ...cutPosition, name: newName } + : cutPosition, + ); + return result; + }, {} as ServerCutResponse), + ); + }, + }); + const { run: runUpateCutHidden } = useRequest(updateCutHidden, { + manual: true, + onSuccess: (_data, [oid, newHidden]) => { + mutateCuts(oldCuts => + Object.keys(oldCuts).reduce((result, key) => { + result[key] = oldCuts[key].map(cutPosition => + cutPosition.oid === oid + ? { ...cutPosition, hidden: newHidden } + : cutPosition, + ); + return result; + }, {} as ServerCutResponse), + ); + }, + }); + const onSectionHiddenChange = useCallback( + (section: string | [number, number], newState: boolean) => { + if (Array.isArray(section)) { + runAddCut(metaData.filename, section[0], section[1], newState); + } else { + runUpateCutHidden(section, newState); + } + }, + [runAddCut, metaData, runUpateCutHidden], + ); + + const [size, sizeRef] = useSize<HTMLDivElement>(); + const [maxWidth, setMaxWidth] = useLocalStorageState("max-width", 1000); + + const [visibleSplits, addVisible, removeVisible] = useSet<PdfSection>(); + const [panelIsOpen, togglePanel] = useToggle(); + const [editState, setEditState] = useState<EditState>({ + mode: EditMode.None, + }); + + const visibleChangeListener = useCallback( + (section: PdfSection, v: boolean) => + v ? addVisible(section) : removeVisible(section), + [addVisible, removeVisible], + ); + const visiblePages = useMemo(() => { + const s = new Set<number>(); + for (const split of visibleSplits) { + s.add(split.start.page); + } + return s; + }, [visibleSplits]); + + const width = size.width; + const wikitransform = metaData.legacy_solution + ? metaData.legacy_solution.split("/").pop() + : ""; + const [displayOptions, setDisplayOptions] = useState({ + displayHiddenPdfSections: false, + displayHiddenAnswerSections: false, + displayHideShowButtons: false, + }); + + const toc = useMemo(() => { + if (sections === undefined) { + return undefined; + } + const rootNode = new TOCNode("[root]", ""); + for (const section of sections) { + if (section.kind === SectionKind.Answer) { + if (section.cutHidden) continue; + const parts = section.name.split(" > "); + if (parts.length === 1 && parts[0].length === 0) continue; + const jumpTarget = `${section.oid}-${parts.join("-")}`; + rootNode.add(parts, jumpTarget); + } + } + if (rootNode.children.length === 0) return undefined; + return rootNode; + }, [sections]); + + return ( + <> + <Container> + {user.isCategoryAdmin && ( + <IconButton + tooltip="Edit exam metadata" + close + icon="EDIT" + onClick={() => toggleEditing()} + /> + )} + <h1>{metaData.displayname}</h1> + <Row form> + {!metaData.canView && ( + <Col md={6} lg={4}> + <Card className="m-1"> + <CardBody> + {metaData.needs_payment && !metaData.hasPayed ? ( + <> + You have to pay a deposit of 20 CHF in the VIS bureau in + order to see oral exams. After submitting a report of your + own oral exam you can get your deposit back. + </> + ) : ( + <>You can not view this exam at this time.</> + )} + </CardBody> + </Card> + </Col> + )} + {metaData.is_printonly && ( + <Col md={6} lg={4}> + <PrintExam + title="exam" + examtype="exam" + filename={metaData.filename} + /> + </Col> + )} + {metaData.has_solution && metaData.solution_printonly && ( + <Col md={6} lg={4}> + <PrintExam + title="solution" + examtype="solution" + filename={metaData.filename} + /> + </Col> + )} + {metaData.legacy_solution && ( + <Col md={6} lg={4}> + <a + href={metaData.legacy_solution} + target="_blank" + rel="noopener noreferrer" + > + <Card className="m-1"> + <Button className="w-100 h-100 p-3"> + Legacy Solution in VISki + </Button> + </Card> + </a> + </Col> + )} + {metaData.legacy_solution && metaData.canEdit && ( + <Col md={6} lg={4}> + <a + href={`/legacy/transformwiki/${wikitransform}`} + target="_blank" + rel="noopener noreferrer" + > + <Card className="m-1"> + <Button className="w-100 h-100 p-3">Transform Wiki</Button> + </Card> + </a> + </Col> + )} + {metaData.master_solution && ( + <Col md={6} lg={4}> + <a + href={metaData.master_solution} + target="_blank" + rel="noopener noreferrer" + > + <Card className="m-1"> + <Button className="w-100 h-100 p-3"> + Official Solution (external) + </Button> + </Card> + </a> + </Col> + )} + + {metaData.has_solution && !metaData.solution_printonly && ( + <Col md={6} lg={4}> + <a + href={`/api/exam/pdf/solution/${metaData.filename}/`} + target="_blank" + rel="noopener noreferrer" + > + <Card className="m-1"> + <Button className="w-100 h-100 p-3">Official Solution</Button> + </Card> + </a> + </Col> + )} + {metaData.attachments.map(attachment => ( + <Col md={6} lg={4} key={attachment.filename}> + <a + href={`/api/filestore/get/${attachment.filename}/`} + target="_blank" + rel="noopener noreferrer" + > + <Card className="m-1"> + <Button className="w-100 h-100 p-3"> + {attachment.displayname} + </Button> + </Card> + </a> + </Col> + ))} + </Row> + {toc && ( + <Row form> + <Col lg={12}> + <TOC toc={toc} /> + </Col> + </Row> + )} + </Container> + + <ContentContainer> + <div ref={sizeRef} style={{ maxWidth }} className="mx-auto my-3"> + {width && sections && renderer && ( + <Exam + metaData={metaData} + sections={sections} + width={width} + editState={editState} + setEditState={setEditState} + reloadCuts={reloadCuts} + renderer={renderer} + onCutNameChange={runUpdateCutName} + onSectionHiddenChange={onSectionHiddenChange} + onAddCut={runAddCut} + onMoveCut={runMoveCut} + visibleChangeListener={visibleChangeListener} + displayHiddenPdfSections={displayOptions.displayHiddenPdfSections} + displayHiddenAnswerSections={ + displayOptions.displayHiddenAnswerSections + } + displayHideShowButtons={displayOptions.displayHideShowButtons} + /> + )} + </div> + </ContentContainer> + <ExamPanel + isOpen={panelIsOpen} + toggle={togglePanel} + metaData={metaData} + renderer={renderer} + visiblePages={visiblePages} + maxWidth={maxWidth} + setMaxWidth={setMaxWidth} + editState={editState} + setEditState={setEditState} + displayOptions={displayOptions} + setDisplayOptions={setDisplayOptions} + /> + </> + ); +}; + +const ExamPage: React.FC<{}> = () => { + const { filename } = useParams() as { filename: string }; + const { + error: metaDataError, + loading: metaDataLoading, + data: metaData, + mutate: setMetaData, + } = useRequest(() => loadExamMetaData(filename), { + cacheKey: `exam-metaData-${filename}`, + }); + useTitle(`${metaData?.displayname ?? filename} - VIS Community Solutions`); + const { + error: cutsError, + loading: cutsLoading, + data: cuts, + run: reloadCuts, + mutate: mutateCuts, + } = useRequest(() => loadCuts(filename), { + cacheKey: `exam-cuts-${filename}`, + }); + const { error: pdfError, loading: pdfLoading, data } = useRequest(() => + loadSplitRenderer(filename), + ); + const [pdf, renderer] = data ? data : []; + const sections = useMemo( + () => (cuts && pdf ? loadSections(pdf.numPages, cuts) : undefined), + [pdf, cuts], + ); + const [editing, toggleEditing] = useToggle(); + const error = metaDataError || cutsError || pdfError; + const user = useUser()!; + return ( + <div> + <Container> + <Breadcrumb> + <BreadcrumbItem> + <Link to="/">Home</Link> + </BreadcrumbItem> + <BreadcrumbItem> + <Link to={`/category/${metaData ? metaData.category : ""}`}> + {metaData && metaData.category_displayname} + </Link> + </BreadcrumbItem> + <BreadcrumbItem>{metaData && metaData.displayname}</BreadcrumbItem> + </Breadcrumb> + </Container> + <div> + {error && ( + <Container> + <Alert color="danger">{error.toString()}</Alert> + </Container> + )} + {metaDataLoading && ( + <Container className="position-absolute"> + <Spinner /> + </Container> + )} + {metaData && + (editing ? ( + <Container> + <ExamMetadataEditor + currentMetaData={metaData} + toggle={toggleEditing} + onMetaDataChange={setMetaData} + /> + </Container> + ) : ( + <UserContext.Provider + value={{ + ...user, + isExpert: user.isExpert || metaData.isExpert, + }} + > + <ExamPageContent + metaData={metaData} + sections={sections} + renderer={renderer} + reloadCuts={reloadCuts} + mutateCuts={mutateCuts} + toggleEditing={toggleEditing} + /> + </UserContext.Provider> + ))} + {(cutsLoading || pdfLoading) && !metaDataLoading && ( + <Container> + <Spinner /> + </Container> + )} + </div> + </div> + ); +}; +export default ExamPage; diff --git a/frontend/src/pages/exam.tsx b/frontend/src/pages/exam.tsx deleted file mode 100644 index 92a2d1de1bfc35040c58ec47694b2415f13a7b47..0000000000000000000000000000000000000000 --- a/frontend/src/pages/exam.tsx +++ /dev/null @@ -1,763 +0,0 @@ -import * as React from "react"; -import { createSectionRenderer, SectionRenderer } from "../split-render"; -import { loadSections } from "../exam-loader"; -import { - ExamMetaData, - PdfSection, - Section, - SectionKind, - AnswerSection, -} from "../interfaces"; -import * as pdfjs from "pdfjs-dist"; -import { debounce } from "lodash"; -import { css } from "glamor"; -import PdfSectionComp from "../components/pdf-section"; -import AnswerSectionComponent from "../components/answer-section"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import MetaData from "../components/metadata"; -import Colors from "../colors"; -import PrintExam from "../components/print-exam"; -import globalcss from "../globalcss"; -import { TOCNode, TOC } from "../components/table-of-contents"; - -const RERENDER_INTERVAL = 500; -const MAX_WIDTH = 1200; - -const styles = { - wrapper: css({ - margin: "auto", - }), - sectionsButtonSticky: css({ - position: ["sticky", "-webkit-sticky"], - top: "20px", - width: "200px", - float: "right", - zIndex: "100", - "@media (max-width: 799px)": { - position: "relative", - top: "unset", - float: "none", - width: "100%", - "& button": { - marginLeft: "0", - marginRight: "0", - }, - }, - }), - sectionsButtons: css({ - position: "absolute", - right: "10px", - "& button": { - width: "100%", - }, - "@media (max-width: 799px)": { - position: "static", - }, - }), - linkBanner: css({ - background: Colors.linkBannerBackground, - width: "60%", - margin: "auto", - marginTop: "10px", - marginBottom: "20px", - padding: "5px 10px", - textAlign: "center", - "@media (max-width: 699px)": { - width: "80%", - }, - }), - checkWrapper: css({ - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - width: "60%", - margin: "auto", - marginBottom: "20px", - textAlign: "center", - padding: "5px 10px", - }), - licenseText: css({ - color: Colors.silentText, - paddingLeft: "10px", - }), -}; - -interface Props { - filename: string; - isAdmin: boolean; -} - -interface State { - moveTarget?: AnswerSection; - pdf?: pdfjs.PDFDocumentProxy; - renderer?: SectionRenderer; - width: number; - dpr: number; - canEdit: boolean; - sections?: Section[]; - allShown: boolean; - editingSectionsActive: boolean; - editingMetaData: boolean; - savedMetaData: ExamMetaData; - updateIntervalId: number; - error?: string; - toc?: TOCNode; -} - -function widthFromWindow(): number { - // This compensates for HTML body padding. - // TODO use a cleaner approach. - return Math.max(0, Math.min(MAX_WIDTH, document.body.clientWidth - 30)); -} - -export default class Exam extends React.Component<Props, State> { - state: State = { - width: widthFromWindow(), - dpr: window.devicePixelRatio, - editingSectionsActive: false, - canEdit: false, - editingMetaData: false, - savedMetaData: { - canEdit: false, - isExpert: false, - canView: true, - hasPayed: false, - filename: "", - category: "", - category_displayname: "", - examtype: "", - displayname: "", - legacy_solution: "", - master_solution: "", - resolve_alias: "", - remark: "", - public: false, - finished_cuts: false, - finished_wiki_transfer: false, - is_printonly: false, - has_solution: false, - solution_printonly: false, - needs_payment: false, - is_oral_transcript: false, - oral_transcript_checked: false, - count_cuts: 0, - count_answered: 0, - attachments: [], - }, - allShown: false, - updateIntervalId: 0, - }; - updateInterval: number | undefined; - cutVersionInterval: number | undefined; - debouncedUpdatePDFWidth: this["updatePDFWidth"]; - - constructor(props: Props) { - super(props); - this.debouncedUpdatePDFWidth = debounce( - this.updatePDFWidth, - RERENDER_INTERVAL, - ); - } - - componentDidMount() { - this.updateInterval = window.setInterval(this.pollZoom, RERENDER_INTERVAL); - window.addEventListener("resize", this.onResize); - this.loadMetaData(); - - this.cutVersionInterval = window.setInterval(this.updateCutVersion, 60000); - - this.loadPDF(); - } - - loadMetaData = () => { - fetchGet(`/api/exam/metadata/${this.props.filename}/`) - .then(res => { - this.setState({ - canEdit: res.value.canEdit, - savedMetaData: res.value, - }); - this.setDocumentTitle(); - }) - .catch(err => { - this.setState({ error: err.toString() }); - }); - }; - - generateTableOfContents = (sections: Section[] | undefined) => { - if (sections === undefined) { - return undefined; - } - const rootNode = new TOCNode("[root]", ""); - for (const section of sections) { - if (section.kind === SectionKind.Answer) { - if (section.cutHidden) continue; - const parts = section.name.split(" > "); - if (parts.length === 1 && parts[0].length === 0) continue; - const jumpTarget = `${section.oid}-${parts.join("-")}`; - rootNode.add(parts, jumpTarget); - } - } - if (rootNode.children.length === 0) return undefined; - return rootNode; - }; - - loadPDF = async () => { - try { - const pdf = await pdfjs.getDocument( - "/api/exam/pdf/exam/" + this.props.filename + "/", - ).promise; - const w = this.state.width * this.state.dpr; - this.setState({ pdf, renderer: await createSectionRenderer(pdf, w) }); - this.loadSectionsFromBackend(pdf.numPages); - } catch (e) { - this.setState({ - error: e.toString(), - }); - } - }; - - setDocumentTitle() { - document.title = - this.state.savedMetaData.displayname + " - VIS Community Solutions"; - } - - componentWillUnmount() { - clearInterval(this.updateInterval); - clearInterval(this.cutVersionInterval); - window.removeEventListener("resize", this.onResize); - if (this.state.renderer) { - this.state.renderer.destroy(); - } - const pdf = this.state.pdf; - if (pdf) { - pdf.destroy(); - } - this.setState({ - pdf: undefined, - renderer: undefined, - }); - } - - componentDidUpdate( - prevProps: Readonly<Props>, - prevState: Readonly<State>, - ): void { - if ( - prevState.dpr !== this.state.dpr || - prevState.width !== this.state.width - ) { - this.debouncedUpdatePDFWidth(); - } - } - - onResize = () => { - const w = widthFromWindow(); - if (w === this.state.width) { - return; - } - this.setState({ width: w }); - }; - - pollZoom = () => { - const dpr = window.devicePixelRatio; - if (dpr === this.state.dpr) { - return; - } - this.setState({ dpr }); - }; - - updatePDFWidth = () => { - const { renderer } = this.state; - if (renderer) { - const w = this.state.width * this.state.dpr; - renderer.setTargetWidth(w); - } - }; - - loadSectionsFromBackend = (numPages: number) => { - loadSections(this.props.filename, numPages) - .then(sections => { - this.setState({ - sections: sections, - toc: this.generateTableOfContents(sections), - }); - }) - .catch(err => { - this.setState({ error: err.toString() }); - }); - }; - - updateCutVersion = () => { - fetchGet(`/api/exam/cutversions/${this.props.filename}/`) - .then(res => { - const versions = res.value; - this.setState(prevState => ({ - sections: prevState.sections - ? prevState.sections.map(section => - section.kind === SectionKind.Answer - ? { ...section, cutVersion: versions[section.oid] } - : section, - ) - : undefined, - })); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - addSection = (ev: React.MouseEvent<HTMLElement>, section: PdfSection) => { - const boundingRect = ev.currentTarget.getBoundingClientRect(); - const yoff = ev.clientY - boundingRect.top; - const relative = yoff / boundingRect.height; - const start = section.start.position; - const end = section.end.position; - let relHeight = start + relative * (end - start); - - if (!ev.shiftKey && this.state.renderer) { - relHeight = this.state.renderer.optimizeCutPosition( - section.start.page - 1, - relHeight, - ); - } - const moveTarget = this.state.moveTarget; - if (moveTarget) { - fetchPost(`/api/exam/editcut/${moveTarget.oid}/`, { - pageNum: section.start.page, - relHeight: relHeight, - }) - .then(() => { - this.setState({ - error: "", - moveTarget: undefined, - }); - if (this.state.pdf) { - this.loadSectionsFromBackend(this.state.pdf.numPages); - } - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } else { - fetchPost(`/api/exam/addcut/${this.props.filename}/`, { - name: "", - pageNum: section.start.page, - relHeight: relHeight, - }) - .then(() => { - this.setState({ - error: "", - }); - if (this.state.pdf) { - this.loadSectionsFromBackend(this.state.pdf.numPages); - } - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } - }; - - gotoPDF = () => { - window.open( - `/api/exam/pdf/exam/${this.props.filename}/?download`, - "_blank", - ); - }; - - reportProblem = () => { - const subject = encodeURIComponent("[VIS] Community Solutions: Feedback"); - const body = encodeURIComponent( - `Concerning the exam '${this.state.savedMetaData.displayname}' of the course '${this.state.savedMetaData.category_displayname}' ...`, - ); - window.location.href = `mailto:communitysolutions@vis.ethz.ch?subject=${subject}&body=${body}`; - }; - - setAllHidden = (hidden: boolean) => { - this.setState(prevState => ({ - sections: prevState.sections - ? prevState.sections.map(section => - section.kind === SectionKind.Answer - ? { ...section, hidden: hidden } - : section, - ) - : undefined, - allShown: !hidden, - })); - }; - - toggleHidden = (sectionOid: string) => { - this.setState(prevState => ({ - allShown: prevState.sections - ? prevState.sections.every( - section => - section.kind === SectionKind.Answer && - section.oid === sectionOid && - section.hidden, - ) - : true, - sections: prevState.sections - ? prevState.sections.map(section => - section.kind === SectionKind.Answer && section.oid === sectionOid - ? { - ...section, - hidden: !section.hidden, - } - : section, - ) - : undefined, - })); - }; - - toggleEditingSectionActive = () => { - this.setState(prevState => { - return { editingSectionsActive: !prevState.editingSectionsActive }; - }); - }; - - toggleEditingMetadataActive = () => { - if (!this.state.editingMetaData) { - window.scrollTo(0, 0); - } - this.setState(prevState => ({ - editingMetaData: !prevState.editingMetaData, - })); - }; - - setAllDone = () => { - const update = { - public: true, - finished_cuts: true, - finished_wiki_transfer: true, - }; - if (this.state.editingMetaData) { - this.toggleEditingMetadataActive(); - } - fetchPost(`/api/exam/setmetadata/${this.props.filename}/`, update).then( - res => { - this.setState(prevState => ({ - savedMetaData: { - ...prevState.savedMetaData, - ...update, - }, - })); - }, - ); - }; - - metaDataChanged = (newMetaData: ExamMetaData) => { - this.setState({ - savedMetaData: newMetaData, - }); - this.setDocumentTitle(); - }; - - markPaymentExamChecked = () => { - fetchPost(`/api/payment/markexamchecked/${this.props.filename}/`, {}) - .then(() => { - this.loadMetaData(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - setHidden = (section: PdfSection, hidden: boolean) => { - const { cutOid } = section; - if (cutOid) { - fetchPost(`/api/exam/editcut/${cutOid}/`, { - hidden, - }).then(() => { - this.setState(prevState => { - const newSections = prevState.sections - ? prevState.sections.map(section => - section.kind === SectionKind.Pdf && section.cutOid === cutOid - ? { ...section, hidden } - : section.kind === SectionKind.Answer && - section.oid === cutOid - ? { ...section, cutHidden: hidden } - : section, - ) - : undefined; - return { - sections: newSections, - toc: this.generateTableOfContents(newSections), - }; - }); - }); - } else { - fetchPost(`/api/exam/addcut/${this.props.filename}/`, { - name: "", - pageNum: section.end.page, - relHeight: section.end.position, - hidden, - }) - .then(() => { - this.setState({ - error: "", - }); - if (this.state.pdf) { - this.loadSectionsFromBackend(this.state.pdf.numPages); - } - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } - }; - render() { - if (!this.state.savedMetaData.canView) { - if ( - this.state.savedMetaData.needs_payment && - !this.state.savedMetaData.hasPayed - ) { - return ( - <div> - You have to pay a deposit of 20 CHF in the VIS bureau in order to - see oral exams. After submitting a report of your own oral exam you - can get your deposit back. - </div> - ); - } - return <div>You can not view this exam at this time.</div>; - } - const { renderer, width, dpr, sections } = this.state; - const wikitransform = this.state.savedMetaData.legacy_solution - ? this.state.savedMetaData.legacy_solution.split("/").pop() - : ""; - return ( - <div> - {this.state.error && ( - <div {...css({ position: ["sticky", "-webkit-sticky"] })}> - {this.state.error} - </div> - )} - <div {...styles.sectionsButtonSticky}> - <div {...styles.sectionsButtons}> - <div> - <button onClick={this.gotoPDF}>Download PDF</button> - </div> - <div> - <button onClick={() => this.setAllHidden(this.state.allShown)}> - {this.state.allShown ? "Hide" : "Show"} All - </button> - </div> - <div> - <button onClick={this.reportProblem}>Report Problem</button> - </div> - {this.state.canEdit && [ - <div key="metadata"> - <button onClick={this.toggleEditingMetadataActive}> - Edit MetaData - </button> - </div>, - !( - this.state.savedMetaData.public && - this.state.savedMetaData.finished_cuts && - this.state.savedMetaData.finished_wiki_transfer - ) && ( - <div key="alldone"> - <button onClick={this.setAllDone}>Set All Done</button> - </div> - ), - <div key="cuts"> - <button onClick={this.toggleEditingSectionActive}> - {(this.state.editingSectionsActive && - "Disable Editing Cuts") || - "Enable Editing Cuts"} - </button> - </div>, - ]} - <div {...styles.licenseText}> - <small {...globalcss.noLinkColor}> - All answers are licensed as <br /> - <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/"> - CC BY-NC-SA 4.0 - </a> - . - </small> - </div> - </div> - </div> - {this.state.editingMetaData && ( - <MetaData - filename={this.props.filename} - savedMetaData={this.state.savedMetaData} - onChange={this.metaDataChanged} - onFinishEdit={this.toggleEditingMetadataActive} - /> - )} - {this.state.savedMetaData.is_oral_transcript && - !this.state.savedMetaData.oral_transcript_checked && ( - <div {...styles.checkWrapper}> - This is a transcript of an oral exam. It needs to be checked - whether it is a valid transcript. - <br /> - <button onClick={this.markPaymentExamChecked}> - Mark Transcript as Checked - </button> - </div> - )} - {this.state.savedMetaData.is_printonly && ( - <PrintExam - title="exam" - examtype="exam" - filename={this.props.filename} - /> - )} - {this.state.savedMetaData.has_solution && - this.state.savedMetaData.solution_printonly && ( - <PrintExam - title="solution" - examtype="solution" - filename={this.props.filename} - /> - )} - {this.state.savedMetaData.legacy_solution && ( - <div {...styles.linkBanner}> - <a - href={this.state.savedMetaData.legacy_solution} - target="_blank" - rel="noopener noreferrer" - > - Legacy Solution in VISki - </a> - {this.state.canEdit && [ - " | ", - <a - href={"/legacy/transformwiki/" + wikitransform} - target="_blank" - key="key" - rel="noopener noreferrer" - > - Transform VISki to Markdown - </a>, - ]} - </div> - )} - {this.state.savedMetaData.master_solution && ( - <div {...styles.linkBanner}> - <a - href={this.state.savedMetaData.master_solution} - target="_blank" - rel="noopener noreferrer" - > - Official Solution (external) - </a> - </div> - )} - {this.state.savedMetaData.has_solution && - !this.state.savedMetaData.solution_printonly && ( - <div {...styles.linkBanner}> - <a - href={"/api/exam/pdf/solution/" + this.props.filename + "/"} - target="_blank" - rel="noopener noreferrer" - > - Official Solution - </a> - </div> - )} - {this.state.savedMetaData.attachments.map(att => ( - <div {...styles.linkBanner} key={att.filename}> - <a - href={"/api/filestore/get/" + att.filename + "/"} - target="_blank" - rel="noopener noreferrer" - > - {att.displayname} - </a> - </div> - ))} - {(renderer && sections && ( - <div style={{ width: width }} {...styles.wrapper}> - {this.state.toc && <TOC toc={this.state.toc} />} - {sections.map(e => { - switch (e.kind) { - case SectionKind.Answer: - return ( - (!e.cutHidden || - (this.state.canEdit && - this.state.editingSectionsActive)) && ( - <AnswerSectionComponent - isMoveTarget={this.state.moveTarget === e} - moveEnabled={this.state.editingSectionsActive} - moveTargetChange={(wantsToBeMoved: boolean) => - this.setState({ - moveTarget: wantsToBeMoved ? e : undefined, - }) - } - name={e.name} - key={e.oid} - isAdmin={this.props.isAdmin} - isExpert={this.state.savedMetaData.isExpert} - filename={this.props.filename} - oid={e.oid} - width={width} - canDelete={this.state.canEdit} - onSectionChange={() => - this.state.pdf - ? this.loadSectionsFromBackend( - this.state.pdf.numPages, - ) - : false - } - onCutNameChange={(newName: string) => { - e.name = newName; - this.setState({ - toc: this.generateTableOfContents( - this.state.sections, - ), - }); - }} - onToggleHidden={() => this.toggleHidden(e.oid)} - hidden={e.hidden} - cutVersion={e.cutVersion} - /> - ) - ); - case SectionKind.Pdf: - return ( - (!e.hidden || - (this.state.canEdit && - this.state.editingSectionsActive)) && ( - <PdfSectionComp - canHide={ - this.state.canEdit && this.state.editingSectionsActive - } - setHidden={(newHidden: boolean) => - this.setHidden(e, newHidden) - } - key={e.key} - section={e} - renderer={renderer} - width={width} - dpr={dpr} - renderText={!this.state.editingSectionsActive} - // ts does not like it if this is undefined... - onClick={ - this.state.canEdit && this.state.editingSectionsActive - ? this.addSection - : ev => ev - } - /> - ) - ); - default: - return null as never; - } - })} - </div> - )) || <p>Loading ...</p>} - </div> - ); - } -} diff --git a/frontend/src/pages/faq-page.tsx b/frontend/src/pages/faq-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5fb7048c2317025e769395ac69b808317fd10ae3 --- /dev/null +++ b/frontend/src/pages/faq-page.tsx @@ -0,0 +1,126 @@ +import { + Card, + CardBody, + CardFooter, + Container, + Input, + Row, + Col, +} from "@vseth/components"; +import * as React from "react"; +import { useState } from "react"; +import { imageHandler } from "../api/fetch-utils"; +import { useFAQ } from "../api/faq"; +import Editor from "../components/Editor"; +import { UndoStack } from "../components/Editor/utils/undo-stack"; +import FAQEntryComponent from "../components/faq-entry"; +import IconButton from "../components/icon-button"; +import MarkdownText from "../components/markdown-text"; +import useTitle from "../hooks/useTitle"; +import { css } from "emotion"; +const newButtonStyle = css` + min-height: 3em; +`; +export const FAQC: React.FC = () => { + useTitle("FAQ - VIS Community Solutions"); + const { faqs, add, update, swap, remove } = useFAQ(); + const [hasDraft, setHasDraft] = useState(false); + const [question, setQuestion] = useState(""); + const [answer, setAnswer] = useState(""); + const [undoStack, setUndoStack] = useState<UndoStack>({ prev: [], next: [] }); + const handleDeleteDraft = () => { + setQuestion(""); + setAnswer(""); + setUndoStack({ prev: [], next: [] }); + setHasDraft(false); + }; + const handleNew = () => { + add( + question, + answer, + faqs?.reduce((old, value) => Math.max(old, value.order), 0) ?? 0, + ); + handleDeleteDraft(); + }; + + return ( + <Container> + <div> + <h1>FAQs</h1> + <p> + If you have any question not yet answered below, feel free to contact + us at{" "} + <a href="mailto:communitysolutions@vis.ethz.ch"> + communitysolutions@vis.ethz.ch + </a> + . + </p> + </div> + {faqs && + faqs.map((faq, idx) => ( + <FAQEntryComponent + key={faq.oid} + entry={faq} + prevEntry={idx > 0 ? faqs[idx - 1] : undefined} + nextEntry={idx + 1 < faqs.length ? faqs[idx + 1] : undefined} + onUpdate={changes => update(faq.oid, changes)} + onSwap={swap} + onRemove={() => remove(faq.oid)} + /> + ))} + {hasDraft ? ( + <Card className="my-2"> + <CardBody> + <h4> + <Input + type="text" + placeholder="Question" + value={question} + onChange={e => setQuestion(e.target.value)} + /> + </h4> + <Editor + imageHandler={imageHandler} + value={answer} + onChange={setAnswer} + undoStack={undoStack} + setUndoStack={setUndoStack} + preview={value => <MarkdownText value={value} />} + /> + </CardBody> + <CardFooter> + <Row className="flex-between"> + <Col xs="auto"> + <IconButton + color="primary" + size="sm" + icon="SAVE" + onClick={handleNew} + > + Save + </IconButton> + </Col> + <Col xs="auto"> + <IconButton size="sm" icon="CLOSE" onClick={handleDeleteDraft}> + Delete Draft + </IconButton> + </Col> + </Row> + </CardFooter> + </Card> + ) : ( + <Card className={`my-2 ${newButtonStyle}`}> + <IconButton + tooltip="Add new FAQ entry" + className="position-cover" + block + size="lg" + icon="PLUS" + onClick={() => setHasDraft(true)} + /> + </Card> + )} + </Container> + ); +}; +export default FAQC; diff --git a/frontend/src/pages/faq.tsx b/frontend/src/pages/faq.tsx deleted file mode 100644 index 699cc56bb829780ae8087698abc3e789b4f69992..0000000000000000000000000000000000000000 --- a/frontend/src/pages/faq.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { fetchGet, fetchPost, imageHandler } from "../fetch-utils"; -import { FAQEntry } from "../interfaces"; -import Editor from "../components/Editor"; -import FAQEntryComponent from "../components/faq-entry"; -import MarkdownText from "../components/markdown-text"; -import { UndoStack } from "../components/Editor/utils/undo-stack"; -import Colors from "../colors"; - -const styles = { - wrapper: css({ - maxWidth: "900px", - margin: "auto", - }), - inputEl: css({ - width: "100%", - marginLeft: 0, - marginRight: 0, - }), - answerInputElPar: css({ - padding: "7px", - border: "1px solid " + Colors.inputBorder, - borderRadius: "2px", - boxSizing: "border-box", - backgroundColor: "white", - }), - answerInputEl: css({ - width: "100%", - padding: "5px", - }), - submitButton: css({ - marginLeft: 0, - marginRight: 0, - minWidth: "100px", - }), -}; - -interface Props { - isAdmin?: boolean; -} - -interface State { - faqs?: FAQEntry[]; - newQuestion: string; - newAnswer: string; - err?: string; - undoStack: UndoStack; -} - -export default class FAQ extends React.Component<Props, State> { - state: State = { - newQuestion: "", - newAnswer: "", - undoStack: { prev: [], next: [] }, - }; - - componentDidMount() { - this.loadFAQs(); - document.title = "FAQ - VIS Community Solutions"; - } - - loadFAQs = () => { - fetchGet("/api/faq/").then(res => { - this.setState({ - faqs: res.value, - }); - }); - }; - - addFAQ = (ev: React.FormEvent<HTMLFormElement>) => { - ev.preventDefault(); - - const newOrder = - this.state.faqs !== undefined && this.state.faqs.length > 0 - ? Math.max(...this.state.faqs.map(x => x.order)) + 1 - : 0; - - fetchPost("/api/faq/", { - question: this.state.newQuestion, - answer: this.state.newAnswer, - order: newOrder, - }) - .then(res => { - this.setState({ - newQuestion: "", - newAnswer: "", - err: "", - undoStack: { prev: [], next: [] }, - }); - this.loadFAQs(); - }) - .catch(res => { - this.setState({ - err: res, - }); - }); - }; - - render() { - const faqs = this.state.faqs; - return ( - <div {...styles.wrapper}> - <div> - <h1>FAQs</h1> - <p> - If you have any question not yet answered below, feel free to - contact us at{" "} - <a href="mailto:communitysolutions@vis.ethz.ch"> - communitysolutions@vis.ethz.ch - </a> - . - </p> - </div> - {this.props.isAdmin && ( - <div> - {this.state.err && <div>{this.state.err}</div>} - <form onSubmit={this.addFAQ}> - <div> - <input - {...styles.inputEl} - type="text" - placeholder="Question" - title="Question" - onChange={event => - this.setState({ newQuestion: event.currentTarget.value }) - } - value={this.state.newQuestion} - /> - </div> - <div {...styles.answerInputElPar}> - <Editor - value={this.state.newAnswer} - onChange={newValue => this.setState({ newAnswer: newValue })} - imageHandler={imageHandler} - preview={str => <MarkdownText value={str} />} - undoStack={this.state.undoStack} - setUndoStack={undoStack => this.setState({ undoStack })} - /> - </div> - <div> - <button - {...styles.submitButton} - type="submit" - disabled={ - this.state.newQuestion.length === 0 || - this.state.newAnswer.length === 0 - } - > - Add - </button> - </div> - </form> - </div> - )} - {faqs && ( - <div> - {faqs.map((faq, idx) => ( - <FAQEntryComponent - key={faq.oid} - isAdmin={this.props.isAdmin} - entry={faq} - prevEntry={idx > 0 ? faqs[idx - 1] : undefined} - nextEntry={idx + 1 < faqs.length ? faqs[idx + 1] : undefined} - entryChanged={this.loadFAQs} - /> - ))} - </div> - )} - </div> - ); - } -} diff --git a/frontend/src/pages/feedback-page.tsx b/frontend/src/pages/feedback-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7d4a81b05abd9b7c7fbfd3ae9d3f0cee1ff35ad9 --- /dev/null +++ b/frontend/src/pages/feedback-page.tsx @@ -0,0 +1,154 @@ +import { useLocalStorageState, useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Col, + Container, + FormGroup, + Input, + Nav, + NavItem, + NavLink, + Row, + Spinner, +} from "@vseth/components"; +import React, { useEffect, useState } from "react"; +import { User, useUser } from "../auth"; +import FeedbackEntryComponent from "../components/feedback-entry"; +import { loadFeedback, submitFeedback } from "../api/hooks"; +import useTitle from "../hooks/useTitle"; + +enum AdminMode { + Read, + Write, +} + +const FeedbackForm: React.FC<{}> = () => { + const [success, setSuccess] = useState(false); + useEffect(() => { + if (success) { + const timeout = window.setTimeout(() => setSuccess(false), 10000); + return () => { + window.clearTimeout(timeout); + }; + } + }); + + const [text, setText] = useState(""); + const { loading, run } = useRequest(submitFeedback, { + manual: true, + onSuccess() { + setText(""); + setSuccess(true); + }, + }); + + return ( + <> + {success && <Alert>Feedback was submited successfully.</Alert>} + <p>Please tell us what you think about the new Community Solutions!</p> + <p>What do you like? What could we improve? Ideas for new features?</p> + <p> + Use the form below or write to{" "} + <a href="mailto:communitysolutions@vis.ethz.ch"> + communitysolutions@vis.ethz.ch + </a> + . + </p> + <p> + To report issues with the platform you can open an issue in our{" "} + <a + href="https://gitlab.ethz.ch/vis/cat/community-solutions/issues" + target="_blank" + rel="noopener noreferrer" + > + {" "} + issue tracker + </a> + . + </p> + <FormGroup> + <Input + type="textarea" + value={text} + onChange={e => setText(e.currentTarget.value)} + rows={12} + /> + </FormGroup> + <FormGroup> + <Button + disabled={text.length === 0 || loading} + onClick={() => run(text)} + > + {loading ? <Spinner /> : "Submit"} + </Button> + </FormGroup> + </> + ); +}; + +const FeedbackReader: React.FC<{}> = () => { + const { error, loading, data: feedback, run: reload } = useRequest( + loadFeedback, + ); + + return ( + <> + {error && <Alert color="danger">{error.message}</Alert>} + {feedback && ( + <Row> + {feedback.map(fb => ( + <Col lg={6} key={fb.oid}> + <FeedbackEntryComponent entry={fb} entryChanged={reload} /> + </Col> + ))} + </Row> + )} + {loading && <Spinner />} + </> + ); +}; + +const FeedbackAdminView: React.FC<{}> = () => { + const [mode, setMode] = useLocalStorageState<AdminMode>( + "feedback-admin-mode", + AdminMode.Read, + ); + return ( + <Container> + <h2>Feedback</h2> + <Nav tabs> + <NavItem> + <NavLink + className={mode === AdminMode.Read ? "active" : ""} + onClick={() => setMode(AdminMode.Read)} + > + Read + </NavLink> + </NavItem> + <NavItem> + <NavLink + className={mode === AdminMode.Write ? "active" : ""} + onClick={() => setMode(AdminMode.Write)} + > + Write + </NavLink> + </NavItem> + </Nav> + {mode === AdminMode.Read ? <FeedbackReader /> : <FeedbackForm />} + </Container> + ); +}; +const FeedbackPage: React.FC<{}> = () => { + useTitle("Feedback - VIS Community Solutions"); + const { isAdmin } = useUser() as User; + return isAdmin ? ( + <FeedbackAdminView /> + ) : ( + <Container> + <h2>Feedback</h2> + <FeedbackForm /> + </Container> + ); +}; +export default FeedbackPage; diff --git a/frontend/src/pages/feedback.tsx b/frontend/src/pages/feedback.tsx deleted file mode 100644 index 5e1aa130577aaee26de60cf99af7d8c2bf5ac829..0000000000000000000000000000000000000000 --- a/frontend/src/pages/feedback.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import { FeedbackEntry } from "../interfaces"; -import FeedbackEntryComponent from "../components/feedback-entry"; -import { Link } from "react-router-dom"; - -const styles = { - wrapper: css({ - maxWidth: "600px", - margin: "auto", - }), - feedbackWrapper: css({ - marginTop: "10px", - marginBottom: "10px", - }), - feedbackTextarea: css({ - width: "100%", - resize: "vertical", - padding: "5px", - boxSizing: "border-box", - }), - submitButton: css({ - textAlign: "right", - "& button": { - width: "50%", - }, - }), - feedbackButton: css({ - textAlign: "center", - }), -}; - -interface Props { - isAdmin?: boolean; -} - -interface State { - feedbackText: string; - result?: string; - feedbackVisible: boolean; - requestedFeedbacks: boolean; - feedbacks?: FeedbackEntry[]; -} - -export default class Feedback extends React.Component<Props, State> { - state: State = { - feedbackText: "", - feedbackVisible: window.location.search === "?show", - requestedFeedbacks: false, - }; - - componentDidMount() { - if (this.props.isAdmin) { - this.loadFeedbacks(); - } - document.title = "Feedback - VIS Community Solutions"; - } - - componentDidUpdate() { - if (this.props.isAdmin && !this.state.requestedFeedbacks) { - this.loadFeedbacks(); - } - } - - feedbackTextareaChange = (event: React.FormEvent<HTMLTextAreaElement>) => { - this.setState({ - feedbackText: event.currentTarget.value, - }); - }; - - submitFeedback = (ev: React.FormEvent<HTMLFormElement>) => { - ev.preventDefault(); - - fetchPost("/api/feedback/submit/", { - text: this.state.feedbackText, - }) - .then(() => { - this.setState({ - feedbackText: "", - result: "Feedback submitted, thank you!", - }); - this.loadFeedbacks(); - }) - .catch(() => { - this.setState({ - result: "Could not submit feedback. Please try again later.", - }); - }); - }; - - loadFeedbacks = () => { - this.setState({ - requestedFeedbacks: true, - }); - fetchGet("/api/feedback/list/") - .then(res => { - const getScore = (a: FeedbackEntry) => - (a.read ? 10 : 0) + (a.done ? 1 : 0); - res.value.sort( - (a: FeedbackEntry, b: FeedbackEntry) => getScore(a) - getScore(b), - ); - this.setState({ - feedbacks: res.value, - }); - }) - .catch(() => undefined); - }; - - toggleFeedbacks = () => { - this.setState(prevState => ({ - feedbackVisible: !prevState.feedbackVisible, - })); - }; - - render() { - return ( - <div {...styles.wrapper}> - <div> - <h1>Feedback</h1> - <p> - Please tell us what you think about the new Community Solutions! - <br /> - What do you like? What could we improve? Ideas for new features? - </p> - <p> - Use the form below or write to{" "} - <a href="mailto:communitysolutions@vis.ethz.ch"> - communitysolutions@vis.ethz.ch - </a> - . If you have new exams you would like to add, first consult the{" "} - <Link to="/faq">FAQ</Link> and then write us an email. - </p> - <p> - To report issues with the platform you can open an issue in our{" "} - <a - href="https://gitlab.ethz.ch/vis/cat/community-solutions/issues" - target="_blank" - rel="noopener noreferrer" - > - issue tracker - </a> - . - </p> - </div> - <div {...styles.feedbackWrapper}> - {this.state.result && <p>{this.state.result}</p>} - <form onSubmit={this.submitFeedback}> - <div> - <textarea - autoFocus={true} - {...styles.feedbackTextarea} - onChange={this.feedbackTextareaChange} - cols={120} - rows={20} - value={this.state.feedbackText} - /> - </div> - {this.state.feedbackText.length > 0 && ( - <div {...styles.submitButton}> - <button type="submit">Send</button> - </div> - )} - </form> - </div> - {this.props.isAdmin && window.location.search === "?show" && ( - <div {...styles.feedbackButton}> - <button onClick={this.toggleFeedbacks}> - {this.state.feedbackVisible ? "Hide Feedback" : "Show Feedback"} - </button> - </div> - )} - {this.state.feedbackVisible && this.state.feedbacks && ( - <div> - {this.state.feedbacks.map(fb => ( - <FeedbackEntryComponent - key={fb.oid} - entry={fb} - entryChanged={this.loadFeedbacks} - /> - ))} - </div> - )} - </div> - ); - } -} diff --git a/frontend/src/pages/home-page.tsx b/frontend/src/pages/home-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fbc21694de87185518b16af8c91e5bdd7a60da2a --- /dev/null +++ b/frontend/src/pages/home-page.tsx @@ -0,0 +1,268 @@ +import { useLocalStorageState, useRequest } from "@umijs/hooks"; +import { + Alert, + Button, + Card, + Col, + Container, + FormGroup, + Icon, + ICONS, + InputField, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Row, + Select, + Spinner, +} from "@vseth/components"; +import React, { useCallback, useMemo, useState } from "react"; +import { fetchGet, fetchPost } from "../api/fetch-utils"; +import { User, useUser } from "../auth"; +import CategoryCard from "../components/category-card"; +import Grid from "../components/grid"; +import LoadingOverlay from "../components/loading-overlay"; +import ContentContainer from "../components/secondary-container"; +import TooltipButton from "../components/TooltipButton"; +import { CategoryMetaData, MetaCategory } from "../interfaces"; +import useTitle from "../hooks/useTitle"; + +enum Mode { + Alphabetical, + BySemester, +} +const options = [ + { value: Mode.Alphabetical.toString(), label: "Alphabetical" }, + { value: Mode.BySemester.toString(), label: "By Semester" }, +]; + +const loadCategories = async () => { + return (await fetchGet("/api/category/listwithmeta/")) + .value as CategoryMetaData[]; +}; +const loadMetaCategories = async () => { + return (await fetchGet("/api/category/listmetacategories/")) + .value as MetaCategory[]; +}; +const loadCategoryData = async () => { + const [categories, metaCategories] = await Promise.all([ + loadCategories(), + loadMetaCategories(), + ]); + return [ + categories.sort((a, b) => a.displayname.localeCompare(b.displayname)), + metaCategories, + ] as const; +}; +const addCategory = async (category: string) => { + await fetchPost("/api/category/add/", { category }); +}; + +const mapToCategories = ( + categories: CategoryMetaData[], + meta1: MetaCategory[], +) => { + const categoryMap = new Map<string, CategoryMetaData>(); + for (const category of categories) categoryMap.set(category.slug, category); + const meta1Map: Map<string, Array<[string, CategoryMetaData[]]>> = new Map(); + for (const { displayname: meta1display, meta2 } of meta1) { + const meta2Map: Map<string, CategoryMetaData[]> = new Map(); + for (const { + displayname: meta2display, + categories: categoryNames, + } of meta2) { + const categories = categoryNames + .map(name => categoryMap.get(name)!) + .filter(a => a !== undefined); + if (categories.length === 0) continue; + meta2Map.set(meta2display, categories); + } + if (meta2Map.size === 0) continue; + meta1Map.set( + meta1display, + [...meta2Map.entries()].sort(([a], [b]) => a.localeCompare(b)), + ); + } + return [...meta1Map.entries()].sort(([a], [b]) => a.localeCompare(b)); +}; + +const AddCategory: React.FC<{ onAddCategory: () => void }> = ({ + onAddCategory, +}) => { + const [isOpen, setIsOpen] = useState(false); + const { loading, run } = useRequest(addCategory, { + manual: true, + onSuccess: () => { + setCategoryName(""); + setIsOpen(false); + onAddCategory(); + }, + }); + const [categoryName, setCategoryName] = useState(""); + const onSubmit = () => { + run(categoryName); + }; + + return ( + <> + <Modal isOpen={isOpen} toggle={() => setIsOpen(false)}> + <ModalHeader>Add Category</ModalHeader> + <ModalBody> + <InputField + label="Category Name" + type="text" + value={categoryName} + onChange={e => setCategoryName(e.currentTarget.value)} + /> + </ModalBody> + <ModalFooter> + <Button + onClick={onSubmit} + disabled={categoryName.length === 0 || loading} + > + {loading ? <Spinner /> : "Add Category"} + </Button> + </ModalFooter> + </Modal> + <Card style={{ minHeight: "10em" }}> + <TooltipButton + tooltip="Add a new category" + onClick={() => setIsOpen(true)} + className="position-cover w-100" + > + <Icon icon={ICONS.PLUS} size={40} className="m-auto" /> + </TooltipButton> + </Card> + </> + ); +}; + +const HomePage: React.FC<{}> = () => { + useTitle("VIS Community Solutions"); + const { isAdmin } = useUser() as User; + const [mode, setMode] = useLocalStorageState("mode", Mode.Alphabetical); + const [filter, setFilter] = useState(""); + const { data, error, loading, run } = useRequest(loadCategoryData, { + cacheKey: "category-data", + }); + const [categoriesWithDefault, metaCategories] = data ? data : []; + + const categories = useMemo( + () => + categoriesWithDefault + ? categoriesWithDefault.filter( + ({ slug }) => slug !== "default" || isAdmin, + ) + : undefined, + [categoriesWithDefault, isAdmin], + ); + const filteredCategories = useMemo( + () => + categories + ? categories.filter(({ displayname }) => + displayname + .toLocaleLowerCase() + .includes(filter.toLocaleLowerCase()), + ) + : undefined, + [filter, categories], + ); + const filteredMetaCategories = useMemo( + () => + filteredCategories && metaCategories + ? mapToCategories(filteredCategories, metaCategories) + : undefined, + [filteredCategories, metaCategories], + ); + + const onAddCategory = useCallback(() => { + run(); + }, [run]); + + return ( + <> + <Container> + <LoadingOverlay loading={loading} /> + <h1>Community Solutions</h1> + </Container> + <Container> + <Row form> + <Col md={4}> + <FormGroup className="m-1"> + <Select + options={[options[Mode.Alphabetical], options[Mode.BySemester]]} + defaultValue={options[mode]} + onChange={(e: any) => setMode(e.value | 0)} + /> + </FormGroup> + </Col> + <Col md={8}> + <FormGroup className="m-1"> + <div className="search m-0"> + <input + type="text" + className="search-input" + placeholder="Filter..." + value={filter} + onChange={e => setFilter(e.currentTarget.value)} + /> + <div className="search-icon-wrapper"> + <div className="search-icon" /> + </div> + </div> + </FormGroup> + </Col> + </Row> + </Container> + <ContentContainer> + <Container> + {error ? ( + <Alert color="danger">{error.toString()}</Alert> + ) : mode === Mode.Alphabetical ? ( + <> + <Grid> + {filteredCategories && + filteredCategories.map(category => ( + <CategoryCard category={category} key={category.slug} /> + ))} + {isAdmin && <AddCategory onAddCategory={onAddCategory} />} + </Grid> + </> + ) : ( + <> + {filteredMetaCategories && + filteredMetaCategories.map(([meta1display, meta2]) => ( + <div key={meta1display}> + <h4>{meta1display}</h4> + {meta2.map(([meta2display, categories]) => ( + <div key={meta2display}> + <h5>{meta2display}</h5> + <Grid> + {categories.map(category => ( + <CategoryCard + category={category} + key={category.slug} + /> + ))} + </Grid> + </div> + ))} + </div> + ))} + {isAdmin && ( + <> + <h4>New Category</h4> + <Grid> + <AddCategory onAddCategory={onAddCategory} /> + </Grid> + </> + )} + </> + )} + </Container> + </ContentContainer> + </> + ); +}; +export default HomePage; diff --git a/frontend/src/pages/home.tsx b/frontend/src/pages/home.tsx deleted file mode 100644 index ce7fcd1a8a05d3b169ae128524b3e824ed3837e2..0000000000000000000000000000000000000000 --- a/frontend/src/pages/home.tsx +++ /dev/null @@ -1,435 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import Colors from "../colors"; -import { - CategoryMetaDataOverview, - MetaCategory, - MetaCategoryWithCategories, -} from "../interfaces"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import { fillMetaCategories, filterCategories } from "../category-utils"; -import { Redirect } from "react-router"; -import { Link } from "react-router-dom"; -import globalcss from "../globalcss"; -import { listenEnter } from "../input-utils"; -import TextLink from "../components/text-link"; - -const styles = { - header: css({ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginLeft: "-15px", - marginRight: "-15px", - marginTop: "-15px", - marginBottom: "20px", - paddingLeft: "15px", - paddingRight: "15px", - paddingTop: "20px", - paddingBottom: "20px", - "@media (max-width: 799px)": { - display: "block", - }, - }), - filterWrapper: css({ - flexGrow: "1", - }), - filterInput: css({ - width: "100%", - display: "flex", - justifyContent: "center", - "& input": { - width: "60%", - "@media (max-width: 799px)": { - width: "80%", - }, - "@media (max-width: 599px)": { - width: "95%", - }, - }, - }), - sortWrapper: css({ - textAlign: "center", - fontSize: "20px", - marginBottom: "10px", - }), - sortWrapperInactive: css({ - cursor: "pointer", - }), - sortWrapperActive: css({ - fontWeight: "bold", - }), - buttonsWrapper: css({ - fontSize: "20px", - display: "flex", - "& div": { - ...globalcss.button, - marginRight: "10px", - }, - "@media (max-width: 799px)": { - marginTop: "10px", - }, - }), - categoriesWrapper: css({ - width: "100%", - display: "flex", - flexWrap: "wrap", - }), - category: css({ - background: Colors.cardBackground, - width: "250px", - padding: "20px", - marginLeft: "20px", - marginRight: "20px", - marginBottom: "40px", - borderRadius: "0px", - boxShadow: Colors.cardShadow, - cursor: "pointer", - "@media (max-width: 699px)": { - width: "100%", - marginLeft: "0", - marginRight: "0", - marginBottom: "10px", - padding: "15px", - }, - }), - categoryTitle: css({ - fontSize: "24px", - textTransform: "capitalize", - marginBottom: "5px", - }), - categoryInfo: css({ - marginTop: "5px", - }), - addCategoryInput: css({ - width: "100%", - marginLeft: "0", - }), - addCategorySubmit: css({ - width: "100%", - marginLeft: "0", - marginBottom: "20px", - }), -}; - -interface Props { - isAdmin?: boolean; - isCategoryAdmin?: boolean; -} - -interface State { - categories: CategoryMetaDataOverview[]; - metaCategories: MetaCategory[]; - filter: string; - bySemesterView: boolean; - gotoCategory?: CategoryMetaDataOverview; - addingCategory: boolean; - newCategoryName: string; - error?: boolean; -} - -export default class Home extends React.Component<Props, State> { - state: State = { - filter: "", - bySemesterView: false, - categories: [], - metaCategories: [], - addingCategory: false, - newCategoryName: "", - }; - - removeDefaultIfNecessary = (categories: CategoryMetaDataOverview[]) => { - if (this.props.isAdmin) { - return categories; - } else { - return categories.filter(cat => cat.displayname !== "default"); - } - }; - - componentDidMount() { - this.loadCategories(); - this.loadMetaCategories(); - this.setState({ - bySemesterView: - (localStorage.getItem("home_bySemesterView") || "0") !== "0", - }); - document.title = "VIS Community Solutions"; - } - - loadCategories = () => { - fetchGet("/api/category/listwithmeta/") - .then(res => { - this.setState({ - categories: this.removeDefaultIfNecessary(res.value), - }); - }) - .catch(() => { - this.setState({ error: true }); - }); - }; - - loadMetaCategories = () => { - fetchGet("/api/category/listmetacategories/").then(res => { - this.setState({ - metaCategories: res.value, - }); - }); - }; - - filterChanged = (ev: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ - filter: ev.target.value, - }); - }; - - openFirstCategory = () => { - const filtered = filterCategories(this.state.categories, this.state.filter); - if (this.state.bySemesterView) { - const categoriesBySemester = fillMetaCategories( - filtered, - this.state.metaCategories, - ); - const resorted: CategoryMetaDataOverview[] = []; - categoriesBySemester.forEach(meta1 => { - meta1.meta2.forEach(meta2 => { - meta2.categories.forEach(cat => resorted.push(cat)); - }); - }); - if (resorted.length > 0) { - this.gotoCategory(resorted[0]); - } - } else { - if (filtered.length > 0) { - this.gotoCategory(filtered[0]); - } - } - }; - - gotoCategory = (cat: CategoryMetaDataOverview) => { - this.setState({ - gotoCategory: cat, - }); - }; - - toggleAddingCategory = () => { - this.setState(prevState => ({ - addingCategory: !prevState.addingCategory, - })); - }; - - addNewCategory = () => { - if (!this.state.newCategoryName) { - return; - } - fetchPost("/api/category/add/", { - category: this.state.newCategoryName, - }) - .then(res => { - this.setState({ - addingCategory: false, - newCategoryName: "", - }); - this.loadCategories(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - setBySemesterView = (bySemester: boolean) => { - this.setState({ - bySemesterView: bySemester, - }); - localStorage.setItem("home_bySemesterView", bySemester ? "1" : "0"); - }; - - addCategoryView = () => { - return ( - <div {...styles.category}> - <div {...styles.categoryTitle} onClick={this.toggleAddingCategory}> - Add Category - </div> - {this.state.addingCategory && ( - <div> - <div> - <input - {...styles.addCategoryInput} - value={this.state.newCategoryName} - onChange={ev => - this.setState({ newCategoryName: ev.target.value }) - } - type="text" - autoFocus={true} - onKeyPress={listenEnter(this.addNewCategory)} - /> - </div> - <div> - <button - {...styles.addCategorySubmit} - disabled={this.state.newCategoryName.length === 0} - onClick={this.addNewCategory} - > - Add Category - </button> - </div> - </div> - )} - </div> - ); - }; - - categoryView = (category: CategoryMetaDataOverview) => { - return ( - <div - key={category.displayname} - {...styles.category} - onClick={() => this.gotoCategory(category)} - > - <div {...styles.categoryTitle} {...globalcss.noLinkColor}> - <Link to={"/category/" + category.slug}>{category.displayname}</Link> - </div> - <div - {...styles.categoryInfo} - title={`There are ${category.examcountpublic} exams, of which ${category.examcountanswered} have at least one answer.`} - > - Exams: {category.examcountanswered} / {category.examcountpublic} - </div> - <div - {...styles.categoryInfo} - title={`Of all questions in all ${ - category.examcountpublic - } exams, ${Math.round( - category.answerprogress * 100, - )}% have an answer.`} - > - Answers: {Math.round(category.answerprogress * 100)}% - </div> - </div> - ); - }; - - alphabeticalView = (categories: CategoryMetaDataOverview[]) => { - return ( - <div {...styles.categoriesWrapper}> - {categories.map(category => this.categoryView(category))} - {this.props.isAdmin && this.addCategoryView()} - </div> - ); - }; - - semesterView = (categories: MetaCategoryWithCategories[]) => { - return ( - <div> - {categories.map(meta1 => ( - <div key={meta1.displayname}> - <h2> - <TextLink to={"#" + meta1.displayname} id={meta1.displayname}> - {meta1.displayname} - </TextLink> - </h2> - {meta1.meta2.map(meta2 => ( - <div key={meta2.displayname}> - <h3> - <TextLink to={"#" + meta2.displayname} id={meta2.displayname}> - {meta2.displayname} - </TextLink> - </h3> - <div {...styles.categoriesWrapper}> - {meta2.categories.map(category => - this.categoryView(category), - )} - </div> - </div> - ))} - </div> - ))} - {this.props.isAdmin && ( - <div> - <h2>New Category</h2> - <div {...styles.categoriesWrapper}>{this.addCategoryView()}</div> - </div> - )} - </div> - ); - }; - - render() { - if (this.state.error) { - return <div>Could not load exams...</div>; - } - if (this.state.gotoCategory) { - return ( - <Redirect - to={"/category/" + this.state.gotoCategory.slug} - push={true} - /> - ); - } - const categories = this.state.categories; - if (!categories) { - return <p>Loading exam list...</p>; - } - const categoriesFiltered = filterCategories(categories, this.state.filter); - const categoriesBySemester = fillMetaCategories( - categoriesFiltered, - this.state.metaCategories, - ); - return ( - <div> - <div {...styles.header}> - <div {...styles.filterWrapper}> - <div {...styles.sortWrapper}> - <span - onClick={() => this.setBySemesterView(false)} - {...(this.state.bySemesterView - ? styles.sortWrapperInactive - : styles.sortWrapperActive)} - > - Alphabetical - </span>{" "} - |{" "} - <span - onClick={() => this.setBySemesterView(true)} - {...(this.state.bySemesterView - ? styles.sortWrapperActive - : styles.sortWrapperInactive)} - > - By Semester - </span> - </div> - <div {...styles.filterInput}> - <input - type="text" - onChange={this.filterChanged} - value={this.state.filter} - placeholder="Filter..." - autoFocus={true} - onKeyPress={listenEnter(this.openFirstCategory)} - /> - </div> - </div> - <div {...styles.buttonsWrapper}> - <div {...globalcss.noLinkColor}> - <Link to="/submittranscript">Submit transcript</Link> - </div> - {this.props.isCategoryAdmin && ( - <div {...globalcss.noLinkColor}> - <Link to="/uploadpdf">Upload Exam</Link> - </div> - )} - {this.props.isCategoryAdmin && ( - <div {...globalcss.noLinkColor}> - <Link to="/modqueue">Mod Queue</Link> - </div> - )} - </div> - </div> - {this.state.bySemesterView - ? this.semesterView(categoriesBySemester) - : this.alphabeticalView(categoriesFiltered)} - </div> - ); - } -} diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31acfd4d6cb42e469cdb1a016fd4f743ef073dae --- /dev/null +++ b/frontend/src/pages/login-page.tsx @@ -0,0 +1,20 @@ +import { Col, Container, Row } from "@vseth/components"; +import React from "react"; +import LoginCard from "../components/login-card"; +import useTitle from "../hooks/useTitle"; + +const LoginPage: React.FC<{}> = () => { + useTitle("Login - VIS Community Solutions"); + return ( + <Container> + <Row> + <Col /> + <Col lg="6"> + <LoginCard /> + </Col> + <Col /> + </Row> + </Container> + ); +}; +export default LoginPage; diff --git a/frontend/src/pages/modqueue-page.tsx b/frontend/src/pages/modqueue-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe13f6aa1b6c89a0e66b6b9cf4b6f2895fdcbff4 --- /dev/null +++ b/frontend/src/pages/modqueue-page.tsx @@ -0,0 +1,159 @@ +import { useRequest } from "@umijs/hooks"; +import { Badge, Button, Container, Table } from "@vseth/components"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { fetchGet } from "../api/fetch-utils"; +import ClaimButton from "../components/claim-button"; +import LoadingOverlay from "../components/loading-overlay"; +import { CategoryExam, CategoryPaymentExam } from "../interfaces"; +import useTitle from "../hooks/useTitle"; + +interface Props { + username: string; + isAdmin: boolean; +} + +interface State { + exams: CategoryExam[]; + paymentExams: CategoryPaymentExam[]; + flaggedAnswers: string[]; + includeHidden: boolean; + error?: string; +} + +const loadExams = async (includeHidden: boolean) => { + return ( + await fetchGet( + `/api/exam/listimportexams/${includeHidden ? "?includehidden=true" : ""}`, + ) + ).value as CategoryExam[]; +}; +const loadPaymentExams = async () => { + return (await fetchGet("/api/exam/listpaymentcheckexams/")) + .value as CategoryPaymentExam[]; +}; +const loadFlagged = async () => { + return (await fetchGet("/api/exam/listflagged/")).value as string[]; +}; + +const ModQueue: React.FC = () => { + useTitle("Import Queue - VIS Community Solutions"); + const [includeHidden, setIncludeHidden] = useState(false); + const { + error: examsError, + loading: examsLoading, + data: exams, + run: reloadExams, + } = useRequest(() => loadExams(includeHidden), { + refreshDeps: [includeHidden], + }); + const { error: flaggedError, data: flaggedAnswers } = useRequest(loadFlagged); + const { + error: payError, + loading: payLoading, + data: paymentExams, + } = useRequest(loadPaymentExams); + + const error = examsError || flaggedError || payError; + + return ( + <Container> + {flaggedAnswers && flaggedAnswers.length > 0 && ( + <div> + <h2>Flagged Answers</h2> + {flaggedAnswers.map(answer => ( + <div> + <a href={answer} target="_blank" rel="noopener noreferrer"> + {answer} + </a> + </div> + ))} + </div> + )} + {paymentExams && paymentExams.length > 0 && ( + <div> + <h2>Transcripts</h2> + <div className="position-relative"> + <LoadingOverlay loading={payLoading} /> + <Table> + <thead> + <tr> + <th>Category</th> + <th>Name</th> + <th>Uploader</th> + </tr> + </thead> + <tbody> + {paymentExams.map(exam => ( + <tr key={exam.filename}> + <td>{exam.category_displayname}</td> + <td> + <Link to={`/exams/${exam.filename}`} target="_blank"> + {exam.displayname} + </Link> + </td> + <td> + <Link to={`/user/${exam.payment_uploader}`}> + {exam.payment_uploader_displayname} + </Link> + </td> + </tr> + ))} + </tbody> + </Table> + </div> + </div> + )} + <h2>Import Queue</h2> + {error && <div>{error}</div>} + <div className="position-relative"> + <LoadingOverlay loading={examsLoading} /> + <Table> + <thead> + <tr> + <th>Category</th> + <th>Name</th> + <th>Import State</th> + <th>Claim</th> + </tr> + </thead> + <tbody> + {exams && + exams.map((exam: CategoryExam) => ( + <tr key={exam.filename}> + <td>{exam.category_displayname}</td> + <td> + <Link to={`/exams/${exam.filename}`} target="_blank"> + {exam.displayname} + </Link> + <div> + <Badge color="primary"> + {exam.public ? "public" : "hidden"} + </Badge> + </div> + <p>{exam.remark}</p> + </td> + <td> + {exam.finished_cuts + ? exam.finished_wiki_transfer + ? "All done" + : "Needs Wiki Import" + : "Needs Cuts"} + </td> + <td> + <ClaimButton exam={exam} reloadExams={reloadExams} /> + </td> + </tr> + ))} + </tbody> + </Table> + </div> + <div> + <Button onClick={() => setIncludeHidden(!includeHidden)}> + {includeHidden ? "Hide" : "Show"} Complete Hidden Exams + </Button> + </div> + </Container> + ); +}; +export default ModQueue; diff --git a/frontend/src/pages/modqueue.tsx b/frontend/src/pages/modqueue.tsx deleted file mode 100644 index 77a902e100b80166624f21980a82b95dc4140e14..0000000000000000000000000000000000000000 --- a/frontend/src/pages/modqueue.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import * as React from "react"; -import { CategoryExam, CategoryPaymentExam } from "../interfaces"; -import { css } from "glamor"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import { Link } from "react-router-dom"; -import colors from "../colors"; -import GlobalConsts from "../globalconsts"; -import moment from "moment"; - -const styles = { - wrapper: css({ - maxWidth: "900px", - margin: "auto", - }), - unviewableExam: css({ - color: colors.inactiveElement, - }), - queueTable: css({ - width: "100%", - marginBottom: "15px", - }), -}; - -interface Props { - username: string; - isAdmin: boolean; -} - -interface State { - exams: CategoryExam[]; - paymentExams: CategoryPaymentExam[]; - flaggedAnswers: string[]; - includeHidden: boolean; - error?: string; -} - -export default class ModQueue extends React.Component<Props, State> { - state: State = { - exams: [], - paymentExams: [], - flaggedAnswers: [], - includeHidden: false, - }; - - componentDidMount() { - this.loadExams(); - this.loadFlagged(); - this.loadPaymentExams(); - document.title = "Import Queue - VIS Community Solutions"; - } - - componentDidUpdate( - prevProps: Readonly<Props>, - prevState: Readonly<State>, - ): void { - if (prevState.includeHidden !== this.state.includeHidden) { - this.loadExams(); - } - if (this.props.isAdmin && !prevProps.isAdmin) { - this.loadPaymentExams(); - this.loadFlagged(); - } - } - - loadExams = () => { - fetchGet( - "/api/exam/listimportexams/" + - (this.state.includeHidden ? "?includehidden=true" : ""), - ) - .then(res => { - this.setState({ - exams: res.value, - }); - }) - .catch(() => undefined); - }; - - loadFlagged = () => { - fetchGet("/api/exam/listflagged/") - .then(res => { - this.setState({ - flaggedAnswers: res.value, - }); - }) - .catch(() => undefined); - }; - - loadPaymentExams = () => { - if (!this.props.isAdmin) { - return; - } - fetchGet("/api/exam/listpaymentcheckexams/") - .then(res => { - this.setState({ - paymentExams: res.value, - }); - }) - .catch(() => undefined); - }; - - setIncludeHidden = (hidden: boolean) => { - this.setState({ - includeHidden: hidden, - }); - }; - - hasValidClaim = (exam: CategoryExam) => { - if (exam.import_claim !== null && exam.import_claim_time !== null) { - if ( - moment().diff( - moment(exam.import_claim_time, GlobalConsts.momentParseString), - ) < - 4 * 60 * 60 * 1000 - ) { - return true; - } - } - return false; - }; - - claimExam = (exam: CategoryExam, claim: boolean) => { - fetchPost(`/api/exam/claimexam/${exam.filename}/`, { - claim: claim, - }) - .then(() => { - this.loadExams(); - if (claim) { - window.open("/exams/" + exam.filename); - } - }) - .catch(err => { - this.setState({ - error: err, - }); - this.loadExams(); - }); - }; - - render() { - if (!this.state.exams) { - return <div>Loading...</div>; - } - return ( - <div {...styles.wrapper}> - {this.state.flaggedAnswers.length > 0 && ( - <div> - <h1>Flagged Answers</h1> - {this.state.flaggedAnswers.map(answer => ( - <div> - <a href={answer} target="_blank" rel="noopener noreferrer"> - {answer} - </a> - </div> - ))} - </div> - )} - {this.state.paymentExams.length > 0 && ( - <div> - <h1>Transcripts</h1> - <table> - <thead> - <tr> - <th>Category</th> - <th>Name</th> - <th>Uploader</th> - </tr> - </thead> - <tbody> - {this.state.paymentExams.map(exam => ( - <tr key={exam.filename}> - <td>{exam.category_displayname}</td> - <td> - <Link to={"/exams/" + exam.filename} target="_blank"> - {exam.displayname} - </Link> - </td> - <td> - <Link to={"/user/" + exam.payment_uploader}> - {exam.payment_uploader_displayname} - </Link> - </td> - </tr> - ))} - </tbody> - </table> - </div> - )} - <h1>Import Queue</h1> - {this.state.error && <div>{this.state.error}</div>} - <table {...styles.queueTable}> - <thead> - <tr> - <th>Category</th> - <th>Name</th> - <th>Remark</th> - <th>Public</th> - <th>Import State</th> - <th>Claim</th> - </tr> - </thead> - <tbody> - {this.state.exams.map(exam => ( - <tr key={exam.filename}> - <td>{exam.category_displayname}</td> - <td> - <Link to={"/exams/" + exam.filename} target="_blank"> - {exam.displayname} - </Link> - </td> - <td>{exam.remark}</td> - <td>{exam.public ? "Public" : "Hidden"}</td> - <td> - {exam.finished_cuts - ? exam.finished_wiki_transfer - ? "All done" - : "Needs Wiki Import" - : "Needs Cuts"} - </td> - <td> - {!exam.finished_cuts || !exam.finished_wiki_transfer ? ( - this.hasValidClaim(exam) ? ( - exam.import_claim === this.props.username ? ( - <button onClick={() => this.claimExam(exam, false)}> - Release Claim - </button> - ) : ( - <span>Claimed by {exam.import_claim_displayname}</span> - ) - ) : ( - <button onClick={() => this.claimExam(exam, true)}> - Claim Exam - </button> - ) - ) : ( - <span>-</span> - )} - </td> - </tr> - ))} - </tbody> - </table> - <div> - <button - onClick={() => this.setIncludeHidden(!this.state.includeHidden)} - > - {this.state.includeHidden ? "Hide" : "Show"} Complete Hidden Exams - </button> - </div> - </div> - ); - } -} diff --git a/frontend/src/pages/not-found-page.tsx b/frontend/src/pages/not-found-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8c45747892005b655a8e9e8aeb47471e3ab5188c --- /dev/null +++ b/frontend/src/pages/not-found-page.tsx @@ -0,0 +1,25 @@ +import { Container, Row, Col } from "@vseth/components"; +import React from "react"; +import useTitle from "../hooks/useTitle"; +import { ReactComponent as Bjoern } from "../assets/bjoern.svg"; + +const NotFoundPage: React.FC<{}> = () => { + useTitle("404 - VIS Community Solutions"); + return ( + <Container className="my-3"> + <Row> + <Col sm={9} md={8} lg={6} className="m-auto"> + <h1>This is a 404.</h1> + <h5> + No need to freak out. Did you enter the URL correctly? For this + inconvenience, have this drawing of Björn: + </h5> + </Col> + <Col sm={9} md={8} lg={6} className="m-auto"> + <Bjoern className="my-2" /> + </Col> + </Row> + </Container> + ); +}; +export default NotFoundPage; diff --git a/frontend/src/pages/scoreboard-page.tsx b/frontend/src/pages/scoreboard-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2acc7c6c3a5670acdffd90b6c8ffe95972b1bce7 --- /dev/null +++ b/frontend/src/pages/scoreboard-page.tsx @@ -0,0 +1,116 @@ +import { useLocalStorageState, useRequest } from "@umijs/hooks"; +import { Alert, Button, Container, Table } from "@vseth/components"; +import React from "react"; +import { Link } from "react-router-dom"; +import LoadingOverlay from "../components/loading-overlay"; +import { fetchGet } from "../api/fetch-utils"; +import { UserInfo } from "../interfaces"; +import useTitle from "../hooks/useTitle"; +import { css } from "emotion"; +const overflowScroll = css` + overflow: scroll; +`; +const modes = [ + "score", + "score_answers", + "score_comments", + "score_cuts", + "score_legacy", +] as const; +type Mode = typeof modes[number]; +const loadScoreboard = async (scoretype: Mode) => { + return (await fetchGet(`/api/scoreboard/top/${scoretype}/`)) + .value as UserInfo[]; +}; +const Scoreboard: React.FC<{}> = () => { + useTitle("Scoreboard - VIS Community Solutions"); + const [mode, setMode] = useLocalStorageState<Mode>( + "scoreboard-mode", + "score", + ); + const { error, loading, data } = useRequest(() => loadScoreboard(mode), { + refreshDeps: [mode], + cacheKey: `scoreboard-${mode}`, + }); + return ( + <Container> + <h1>Scoreboard</h1> + {error && <Alert color="danger">{error.message}</Alert>} + <LoadingOverlay loading={loading} /> + <div className={overflowScroll}> + <Table> + <thead> + <tr> + <th>Rank</th> + <th>User</th> + <th> + <Button + color="white" + onClick={() => setMode("score")} + active={mode === "score"} + > + Score + </Button> + </th> + <th> + <Button + color="white" + onClick={() => setMode("score_answers")} + active={mode === "score_answers"} + > + Answers + </Button> + </th> + <th> + <Button + color="white" + onClick={() => setMode("score_comments")} + active={mode === "score_comments"} + > + Comments + </Button> + </th> + <th> + <Button + color="white" + onClick={() => setMode("score_cuts")} + active={mode === "score_cuts"} + > + Import Exams + </Button> + </th> + <th> + <Button + color="white" + onClick={() => setMode("score_legacy")} + active={mode === "score_legacy"} + > + Import Wiki + </Button> + </th> + </tr> + </thead> + <tbody> + {data && + data.map((board, idx) => ( + <tr key={board.username}> + <td>{idx + 1}</td> + <td> + <Link to={`/user/${board.username}`}> + {board.displayName} + </Link> + </td> + <td>{board.score}</td> + <td>{board.score_answers}</td> + <td>{board.score_comments}</td> + <td>{board.score_cuts}</td> + <td>{board.score_legacy}</td> + </tr> + ))} + </tbody> + </Table> + </div> + </Container> + ); +}; +export default Scoreboard; diff --git a/frontend/src/pages/scoreboard.tsx b/frontend/src/pages/scoreboard.tsx deleted file mode 100644 index 2c6c1b095d50eb28fe3d0db975334bd60e43cdbc..0000000000000000000000000000000000000000 --- a/frontend/src/pages/scoreboard.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import * as React from "react"; -import { css } from "glamor"; -import { UserInfo } from "../interfaces"; -import { fetchGet } from "../fetch-utils"; -import { Link } from "react-router-dom"; -import globalcss from "../globalcss"; - -const styles = { - wrapper: css({ - maxWidth: "900px", - margin: "auto", - }), - canClick: css({ - cursor: "pointer", - }), - scoreboardTable: css({ - width: "100%", - }), - header: css({ - "& th": { - padding: "15px", - }, - }), - row: css({ - padding: "5px", - "& td": { - padding: "15px", - }, - }), - scrollOverflow: css({ - overflowX: "auto", - boxShadow: "grey 0px 2px 4px 0px", - }), -}; - -interface Props { - username: string; -} - -interface State { - scoreboard: UserInfo[]; - error?: string; -} - -export default class Scoreboard extends React.Component<Props, State> { - state: State = { - scoreboard: [], - }; - - componentDidMount() { - const hash = window.location.hash.substr(1); - if ( - [ - "score", - "score_answers", - "score_comments", - "score_cuts", - "score_legacy", - ].indexOf(hash) !== -1 - ) { - this.loadScoreboard(hash); - } else { - this.loadScoreboard("score"); - } - document.title = "Scoreboard - VIS Community Solutions"; - } - - loadScoreboard = (scoretype: string) => { - window.location.hash = scoretype; - fetchGet("/api/scoreboard/top/" + scoretype + "/") - .then(res => { - this.setState({ - scoreboard: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - render() { - return ( - <div {...styles.wrapper}> - {this.state.error && <p>{this.state.error}</p>} - <h1>Scoreboard</h1> - <div {...styles.scrollOverflow}> - <table {...styles.scoreboardTable}> - <thead> - <tr {...styles.header}> - <th>Rank</th> - <th>User</th> - <th - {...styles.canClick} - onClick={() => this.loadScoreboard("score")} - > - Score - </th> - <th - {...styles.canClick} - onClick={() => this.loadScoreboard("score_answers")} - > - Answers - </th> - <th - {...styles.canClick} - onClick={() => this.loadScoreboard("score_comments")} - > - Comments - </th> - <th - {...styles.canClick} - onClick={() => this.loadScoreboard("score_cuts")} - > - Import Exams - </th> - <th - {...styles.canClick} - onClick={() => this.loadScoreboard("score_legacy")} - > - Import Wiki - </th> - </tr> - </thead> - <tbody> - {this.state.scoreboard.map((board, idx) => ( - <tr key={board.username} {...styles.row}> - <td>{idx + 1}</td> - <td {...globalcss.noLinkColor}> - <Link to={"/user/" + board.username}> - {board.displayName} - </Link> - </td> - <td>{board.score}</td> - <td>{board.score_answers}</td> - <td>{board.score_comments}</td> - <td>{board.score_cuts}</td> - <td>{board.score_legacy}</td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - ); - } -} diff --git a/frontend/src/pages/submittranscript-page.tsx b/frontend/src/pages/submittranscript-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eac12921c6e5b7366abfb8ab3384de9a5d01a658 --- /dev/null +++ b/frontend/src/pages/submittranscript-page.tsx @@ -0,0 +1,20 @@ +import { Col, Container, Row } from "@vseth/components"; +import React from "react"; +import UploadTranscriptCard from "../components/upload-transcript-card"; +import useTitle from "../hooks/useTitle"; + +const UploadTranscriptPage: React.FC<{}> = () => { + useTitle("Upload Transcript - VIS Community Solutions"); + return ( + <Container> + <Row> + <Col /> + <Col lg="6"> + <UploadTranscriptCard /> + </Col> + <Col /> + </Row> + </Container> + ); +}; +export default UploadTranscriptPage; diff --git a/frontend/src/pages/submittranscript.tsx b/frontend/src/pages/submittranscript.tsx deleted file mode 100644 index 4361c4a0d8a058b098edff74be65421d2b93397a..0000000000000000000000000000000000000000 --- a/frontend/src/pages/submittranscript.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import * as React from "react"; -import { Redirect } from "react-router-dom"; -import { css } from "glamor"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import AutocompleteInput from "../components/autocomplete-input"; -import Colors from "../colors"; -import { CategoryMetaDataMinimal } from "../interfaces"; - -interface State { - file: Blob; - category: string; - categories: CategoryMetaDataMinimal[]; - result?: { filename: string }; - error?: string; -} - -const styles = { - wrapper: css({ - width: "430px", - margin: "auto", - padding: "10px", - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - "& div": { - width: "100%", - }, - "& input, & button": { - width: "415px", - }, - }), -}; - -export default class SubmitTranscript extends React.Component<{}, State> { - state: State = { - file: new Blob(), - category: "", - categories: [], - }; - - componentDidMount() { - fetchGet("/api/category/listonlypayment/") - .then(res => - this.setState({ - categories: res.value, - }), - ) - .catch(e => { - this.setState({ error: e.toString() }); - }); - document.title = "Submit Transcript - VIS Community Solutions"; - } - - handleUpload = (ev: React.FormEvent<HTMLFormElement>) => { - ev.preventDefault(); - - fetchPost("/api/exam/upload/transcript/", { - file: this.state.file, - category: this.state.category, - }) - .then(body => - this.setState({ - result: body, - error: undefined, - }), - ) - .catch(e => { - this.setState({ error: e.toString() }); - }); - }; - - handleFileChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - file: ev.target.files[0], - }); - } - }; - - handleCategoryChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ - category: ev.target.value, - }); - }; - - render() { - if (this.state.result) { - return <Redirect to="/" push={true} />; - } else { - return ( - <div {...styles.wrapper}> - <h2>Submit Transcript for Oral Exam</h2> - {this.state.error && <p>{this.state.error}</p>} - <p> - Please use the following{" "} - <a href="/static/transcript_template.tex">template</a>. - </p> - <form onSubmit={this.handleUpload}> - <div> - <input - onChange={this.handleFileChange} - type="file" - accept="application/pdf" - /> - </div> - <div> - <AutocompleteInput - name="category" - onChange={this.handleCategoryChange} - value={this.state.category} - placeholder="category..." - autocomplete={this.state.categories.map(cat => cat.displayname)} - /> - </div> - <div> - <button type="submit">Submit</button> - </div> - </form> - </div> - ); - } - } -} diff --git a/frontend/src/pages/tutorial.tsx b/frontend/src/pages/tutorial-page.tsx similarity index 85% rename from frontend/src/pages/tutorial.tsx rename to frontend/src/pages/tutorial-page.tsx index c59c94221ec29bbecd5d5d413da54492260892a6..de151fc4731bf5fe24961bf914f473c9dd6e057c 100644 --- a/frontend/src/pages/tutorial.tsx +++ b/frontend/src/pages/tutorial-page.tsx @@ -1,6 +1,8 @@ import * as React from "react"; -import { css } from "glamor"; +import { css } from "emotion"; import { useState } from "react"; +import useTitle from "../hooks/useTitle"; +import GlobalConsts from "../globalconsts"; interface SectionProps { backgroundColor?: string; background?: string; @@ -29,7 +31,7 @@ const Section: React.FC<SectionProps> = ({ backgroundRepeat: "no-repeat", } } - {...sectionStyle} + className={sectionStyle} > <div style={{ @@ -45,31 +47,31 @@ const Section: React.FC<SectionProps> = ({ ); }; -const slideshowStyle = css({ - position: "absolute", - top: "0", - left: "0", - right: "0", - bottom: "0", - zIndex: "100000", - background: "white", -}); -const leftPanelStyle = css({ - position: "absolute", - left: "0", - top: "0", - bottom: "0", - width: "50%", - zIndex: "100001", -}); -const rightPanelStyle = css({ - position: "absolute", - right: "0", - top: "0", - bottom: "0", - width: "50%", - zIndex: "100001", -}); +const slideshowStyle = css` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: ${GlobalConsts.zIndex.tutorialSlideShow}; + background: white; +`; +const leftPanelStyle = css` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 50%; + z-index: ${GlobalConsts.zIndex.tutorialSlideShowOverlayArea}; +`; +const rightPanelStyle = css` + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 50%; + z-index: ${GlobalConsts.zIndex.tutorialSlideShowOverlayArea}; +`; interface SlideshowProps { children: React.ReactElement<typeof Section>[]; } @@ -77,15 +79,15 @@ const wrappingMod = (a: number, b: number) => ((a % b) + b) % b; const Slideshow: React.FC<SlideshowProps> = ({ children }) => { const [slideNumber, setSlideNumber] = useState(0); return ( - <div {...slideshowStyle}> + <div className={slideshowStyle}> <div - {...leftPanelStyle} + className={leftPanelStyle} onClick={() => setSlideNumber(prevNum => wrappingMod(prevNum - 1, children.length)) } /> <div - {...rightPanelStyle} + className={rightPanelStyle} onClick={() => setSlideNumber(prevNum => wrappingMod(prevNum + 1, children.length)) } @@ -95,6 +97,7 @@ const Slideshow: React.FC<SlideshowProps> = ({ children }) => { ); }; const TutorialPage: React.FC<{}> = () => { + useTitle("Tutorial - VIS Community Solutions"); return ( <Slideshow> <Section backgroundColor="#394b59"> diff --git a/frontend/src/pages/uploadpdf-page.tsx b/frontend/src/pages/uploadpdf-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c492fafbc2c2dedd809dd3a59a5d937bba82c18e --- /dev/null +++ b/frontend/src/pages/uploadpdf-page.tsx @@ -0,0 +1,20 @@ +import { Col, Container, Row } from "@vseth/components"; +import React from "react"; +import UploadPdfCard from "../components/upload-pdf-card"; +import useTitle from "../hooks/useTitle"; + +const UploadPdfPage: React.FC<{}> = () => { + useTitle("Upload PDF - VIS Community Solutions"); + return ( + <Container> + <Row> + <Col /> + <Col lg="6"> + <UploadPdfCard /> + </Col> + <Col /> + </Row> + </Container> + ); +}; +export default UploadPdfPage; diff --git a/frontend/src/pages/uploadpdf.tsx b/frontend/src/pages/uploadpdf.tsx deleted file mode 100644 index 7b54150791219e311e9b66e19838f67e603a89d3..0000000000000000000000000000000000000000 --- a/frontend/src/pages/uploadpdf.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import * as React from "react"; -import { Redirect } from "react-router-dom"; -import { css } from "glamor"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import AutocompleteInput from "../components/autocomplete-input"; -import Colors from "../colors"; -import { CategoryMetaDataMinimal } from "../interfaces"; -import { findCategoryByName } from "../category-utils"; - -interface State { - file: Blob; - displayName: string; - category: string; - categories: CategoryMetaDataMinimal[]; - result?: { filename: string }; - error?: string; -} - -const styles = { - wrapper: css({ - width: "430px", - margin: "auto", - padding: "10px", - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - "& div": { - width: "100%", - }, - "& input, & button": { - width: "415px", - }, - }), -}; - -export default class UploadPDF extends React.Component<{}, State> { - state: State = { - file: new Blob(), - displayName: "", - category: "", - categories: [], - }; - - componentDidMount() { - fetchGet("/api/category/listonlyadmin/") - .then(res => - this.setState({ - categories: res.value, - }), - ) - .catch(e => { - this.setState({ error: e.toString() }); - }); - document.title = "Upload Exam - VIS Community Solutions"; - } - - handleUpload = (ev: React.FormEvent<HTMLFormElement>) => { - ev.preventDefault(); - - const category = findCategoryByName( - this.state.categories, - this.state.category, - ); - if (category === null) { - this.setState({ error: "Could not find category." }); - return; - } - - fetchPost("/api/exam/upload/exam/", { - file: this.state.file, - displayname: this.state.displayName, - category: category.slug, - }) - .then(body => - this.setState({ - result: body, - error: undefined, - }), - ) - .catch(e => { - this.setState({ error: e.toString() }); - }); - }; - - handleFileChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - if (ev.target.files != null) { - this.setState({ - file: ev.target.files[0], - }); - } - }; - - handleDisplayNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ - displayName: ev.target.value, - }); - }; - - handleCategoryChange = (ev: React.ChangeEvent<HTMLInputElement>) => { - this.setState({ - category: ev.target.value, - }); - }; - - render() { - if (this.state.result) { - return ( - <Redirect to={"/exams/" + this.state.result.filename} push={true} /> - ); - } else { - return ( - <div {...styles.wrapper}> - <h2>Upload PDF</h2> - {this.state.error && <p>{this.state.error}</p>} - <form onSubmit={this.handleUpload}> - <div> - <input - onChange={this.handleFileChange} - type="file" - accept="application/pdf" - /> - </div> - <div> - <input - onChange={this.handleDisplayNameChange} - value={this.state.displayName} - type="text" - placeholder="displayname..." - required - /> - </div> - <div> - <AutocompleteInput - name="category" - onChange={this.handleCategoryChange} - value={this.state.category} - placeholder="category..." - autocomplete={this.state.categories.map(cat => cat.displayname)} - /> - </div> - <div> - <button type="submit">Upload</button> - </div> - </form> - </div> - ); - } - } -} diff --git a/frontend/src/pages/userinfo-page.tsx b/frontend/src/pages/userinfo-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b8b48e61dfd774ad7b1948ae699e64704184a3b --- /dev/null +++ b/frontend/src/pages/userinfo-page.tsx @@ -0,0 +1,49 @@ +import { Alert, Col, Container, Row, Spinner } from "@vseth/components"; +import React from "react"; +import { useParams } from "react-router-dom"; +import { useUser } from "../auth"; +import UserAnswers from "../components/user-answers"; +import UserNotifications from "../components/user-notifications"; +import UserPayments from "../components/user-payments"; +import UserScoreCard from "../components/user-score-card"; +import { useUserInfo } from "../api/hooks"; +import ContentContainer from "../components/secondary-container"; +import useTitle from "../hooks/useTitle"; +const UserPage: React.FC<{}> = () => { + const { username } = useParams() as { username: string }; + useTitle(`${username} - VIS Community Solutions`); + const user = useUser()!; + const isMyself = user.username === username; + const [userInfoError, userInfoLoading, userInfo] = useUserInfo(username); + const error = userInfoError; + const loading = userInfoLoading; + return ( + <> + <Container> + <UserScoreCard + username={username} + isMyself={isMyself} + userInfo={userInfo} + /> + {error && <Alert color="danger">{error.toString()}</Alert>} + {loading && <Spinner />} + </Container> + <ContentContainer> + <Container> + {(isMyself || user.isAdmin) && <UserPayments username={username} />} + <Row> + <Col sm={{ size: 12, order: 1 }} md={{ size: 6, order: 0 }}> + <UserAnswers username={username} /> + </Col> + {isMyself && ( + <Col sm={{ size: 12, order: 0 }} md={{ size: 6, order: 1 }}> + <UserNotifications username={username} /> + </Col> + )} + </Row> + </Container> + </ContentContainer> + </> + ); +}; +export default UserPage; diff --git a/frontend/src/pages/userinfo.tsx b/frontend/src/pages/userinfo.tsx deleted file mode 100644 index 8976d0bd57809154ae1e86964fbdf72698be8cd4..0000000000000000000000000000000000000000 --- a/frontend/src/pages/userinfo.tsx +++ /dev/null @@ -1,592 +0,0 @@ -import * as React from "react"; -import { fetchGet, fetchPost } from "../fetch-utils"; -import { Answer, NotificationInfo, PaymentInfo, UserInfo } from "../interfaces"; -import NotificationComponent from "../components/notification"; -import { css } from "glamor"; -import colors from "../colors"; -import moment from "moment"; -import GlobalConsts from "../globalconsts"; -import { Link } from "react-router-dom"; -import Colors from "../colors"; -import AnswerComponent from "../components/answer"; - -const styles = { - wrapper: css({ - maxWidth: "1200px", - margin: "auto", - }), - scoreWrapper: css({ - display: "flex", - flexWrap: "wrap", - justifyContent: "space-around", - marginTop: "40px", - marginBottom: "20px", - }), - card: css({ - background: Colors.cardBackground, - boxShadow: Colors.cardShadow, - padding: "15px 40px", - marginTop: "40px", - marginBottom: "80px", - }), - score: css({ - minWidth: "140px", - textAlign: "center", - fontSize: "20px", - }), - scoreNumber: css({ - fontSize: "72px", - }), - multiRows: css({ - display: "flex", - justifyContent: "space-between", - flexWrap: "wrap", - }), - rowContent: css({ - width: "100%", - boxSizing: "border-box", - paddingLeft: "20px", - paddingRight: "20px", - flexGrow: "1", - "@media (min-width: 799px)": { - width: "50%", - }, - }), - notificationSettings: css({ - marginBottom: "40px", - }), - clickable: css({ - cursor: "pointer", - }), - paymentWrapper: css({ - marginLeft: "20px", - marginRight: "20px", - marginBottom: "40px", - }), - payment: css({ - background: colors.cardBackground, - boxShadow: colors.cardShadow, - padding: "5px", - maxWidth: "300px", - }), - paymentInactive: css({ - color: colors.inactiveElement, - }), - logoutText: css({ - marginLeft: "10px", - cursor: "pointer", - fontSize: "medium", - }), -}; - -interface Props { - isMyself: boolean; - isAdmin: boolean; - username: string; - userinfoChanged: () => void; -} - -interface State { - userInfo: UserInfo; - showAllNotifications: boolean; - showReadNotifications: boolean; - showAllAnswers: boolean; - notifications: NotificationInfo[]; - payments: PaymentInfo[]; - answers: Answer[]; - openPayment: string; - enabledNotifications: number[]; - newPaymentCategory: string; - error?: string; -} - -export default class UserInfoComponent extends React.Component<Props, State> { - state: State = { - userInfo: { - username: this.props.username, - displayName: "Loading...", - score: 0, - score_answers: 0, - score_comments: 0, - score_cuts: 0, - score_legacy: 0, - }, - showAllNotifications: false, - showReadNotifications: false, - showAllAnswers: false, - notifications: [], - payments: [], - answers: [], - openPayment: "", - enabledNotifications: [], - newPaymentCategory: "", - }; - - componentDidMount() { - this.loadUserInfo(); - this.loadAnswers(); - if (this.props.isMyself) { - this.loadUnreadNotifications(); - this.loadEnabledNotifications(); - this.loadPayments(); - } - if (this.props.isAdmin) { - this.loadPayments(); - } - } - - componentDidUpdate(prevProps: Props) { - document.title = - this.state.userInfo.displayName + " - VIS Community Solutions"; - if (!prevProps.isMyself && this.props.isMyself) { - this.loadUnreadNotifications(); - this.loadEnabledNotifications(); - this.loadPayments(); - } - if (!prevProps.isAdmin && this.props.isAdmin) { - this.loadPayments(); - } - if (prevProps.username !== this.props.username) { - this.loadUserInfo(); - this.loadAnswers(); - if (this.props.isAdmin) { - this.loadPayments(); - } - } - } - - loadUserInfo = () => { - fetchGet("/api/scoreboard/userinfo/" + this.props.username + "/") - .then(res => { - this.setState({ - userInfo: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - loadEnabledNotifications = () => { - fetchGet("/api/notification/getenabled/") - .then(res => { - this.setState({ - enabledNotifications: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - loadPayments = () => { - const query = this.props.isMyself - ? "/api/payment/me/" - : "/api/payment/query/" + this.props.username + "/"; - fetchGet(query) - .then(res => { - this.setState({ - payments: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - addPayment = () => { - fetchPost("/api/payment/pay/", { - username: this.props.username, - }) - .then(() => { - this.loadPayments(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - removePayment = (payment: PaymentInfo) => { - // eslint-disable-next-line no-restricted-globals - const confirmation = confirm("Remove Payment?"); - if (confirmation) { - fetchPost("/api/payment/remove/" + payment.oid + "/", {}) - .then(() => { - this.loadPayments(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } - }; - - refundPayment = (payment: PaymentInfo) => { - let confirmation = true; - if (!payment.uploaded_filename) { - // eslint-disable-next-line no-restricted-globals - confirmation = confirm( - "The payment does not have any associated exams. Really refund?", - ); - } - if (confirmation) { - fetchPost("/api/payment/refund/" + payment.oid + "/", {}) - .then(() => { - this.loadPayments(); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - } - }; - - loadUnreadNotifications = () => { - fetchGet("/api/notification/unread/") - .then(res => { - this.setState({ - notifications: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - loadAllNotifications = () => { - fetchGet("/api/notification/all/") - .then(res => { - this.setState({ - showReadNotifications: true, - notifications: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - setNotificationEnabled = (type: number, enabled: boolean) => { - fetchPost("/api/notification/setenabled/", { - type: type, - enabled: enabled, - }).then(() => { - this.loadEnabledNotifications(); - }); - }; - - markAllRead = () => { - Promise.all( - this.state.notifications - .filter(notification => !notification.read) - .map(notification => - fetchPost("/api/notification/setread/" + notification.oid + "/", { - read: true, - }).catch(err => { - this.setState({ - error: err.toString(), - }); - }), - ), - ) - .then(() => { - if (this.state.showReadNotifications) { - this.loadAllNotifications(); - } else { - this.loadUnreadNotifications(); - } - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - loadAnswers = () => { - fetchGet("/api/exam/listbyuser/" + this.props.username + "/") - .then(res => { - this.setState({ - answers: res.value, - }); - }) - .catch(err => { - this.setState({ - error: err.toString(), - }); - }); - }; - - logoutUser = () => { - fetchPost("/api/auth/logout/", {}).then(() => { - this.props.userinfoChanged(); - }); - }; - - renderScoreCard = () => ( - <div {...styles.card}> - <h1> - {this.state.userInfo.displayName} - {this.props.isMyself && ( - <span onClick={this.logoutUser} {...styles.logoutText}> - (Logout) - </span> - )} - </h1> - <div {...styles.scoreWrapper}> - <div {...styles.score}> - <div>Score</div> - <div {...styles.scoreNumber}>{this.state.userInfo.score}</div> - </div> - <div {...styles.score}> - <div>Answers</div> - <div {...styles.scoreNumber}>{this.state.userInfo.score_answers}</div> - </div> - <div {...styles.score}> - <div>Comments</div> - <div {...styles.scoreNumber}> - {this.state.userInfo.score_comments} - </div> - </div> - {this.state.userInfo.score_cuts > 0 && ( - <div {...styles.score}> - <div>Exam Import</div> - <div {...styles.scoreNumber}>{this.state.userInfo.score_cuts}</div> - </div> - )} - {this.state.userInfo.score_legacy > 0 && ( - <div {...styles.score}> - <div>Wiki Import</div> - <div {...styles.scoreNumber}> - {this.state.userInfo.score_legacy} - </div> - </div> - )} - </div> - </div> - ); - - renderPayments = () => ( - <div {...styles.paymentWrapper}> - <h2>Paid Oral Exams</h2> - <div> - {this.state.payments - .filter(payment => payment.active) - .map(payment => ( - <div key={payment.oid}> - You have paid for all oral exams until{" "} - {moment( - payment.valid_until, - GlobalConsts.momentParseString, - ).format(GlobalConsts.momentFormatStringDate)} - . - </div> - ))} - {this.state.payments.length > 0 && ( - <ul> - {this.state.payments.map(payment => ( - <li key={payment.oid}> - {(this.state.openPayment === payment.oid && ( - <div {...styles.payment}> - <div - {...styles.clickable} - {...(payment.active ? undefined : styles.paymentInactive)} - onClick={() => this.setState({ openPayment: "" })} - > - <b> - Payment Time:{" "} - {moment( - payment.payment_time, - GlobalConsts.momentParseString, - ).format(GlobalConsts.momentFormatString)} - </b> - </div> - <div> - Valid Until:{" "} - {moment( - payment.valid_until, - GlobalConsts.momentParseString, - ).format(GlobalConsts.momentFormatStringDate)} - </div> - {payment.refund_time && ( - <div> - Refund Time:{" "} - {moment( - payment.refund_time, - GlobalConsts.momentParseString, - ).format(GlobalConsts.momentFormatString)} - </div> - )} - {payment.uploaded_filename && ( - <div> - <Link to={"/exams/" + payment.uploaded_filename}> - Uploaded Transcript - </Link> - </div> - )} - {!payment.refund_time && this.props.isAdmin && ( - <div> - <button onClick={() => this.refundPayment(payment)}> - Mark Refunded - </button> - <button onClick={() => this.removePayment(payment)}> - Remove Payment - </button> - </div> - )} - </div> - )) || ( - <span - {...styles.clickable} - {...(payment.active ? undefined : styles.paymentInactive)} - onClick={() => this.setState({ openPayment: payment.oid })} - > - Payment Time:{" "} - {moment( - payment.payment_time, - GlobalConsts.momentParseString, - ).format(GlobalConsts.momentFormatString)} - </span> - )} - </li> - ))} - </ul> - )} - {this.props.isAdmin && - this.state.payments.filter(payment => payment.active).length === - 0 && ( - <div> - <button onClick={this.addPayment}>Add Payment</button> - </div> - )} - </div> - </div> - ); - - renderNotifications = () => { - let notifications = this.state.notifications.slice().reverse(); - if (!this.state.showAllNotifications && notifications.length > 5) { - notifications = notifications.slice(0, 5); - } - return ( - <div {...styles.rowContent}> - <h2>Notifications</h2> - <div {...styles.notificationSettings}> - <div> - <input - type="checkbox" - checked={this.state.enabledNotifications.indexOf(1) !== -1} - onChange={ev => this.setNotificationEnabled(1, ev.target.checked)} - />{" "} - Comment to my answer - </div> - <div> - <input - type="checkbox" - checked={this.state.enabledNotifications.indexOf(2) !== -1} - onChange={ev => this.setNotificationEnabled(2, ev.target.checked)} - />{" "} - Comment to my comment - </div> - <div> - <input - type="checkbox" - checked={this.state.enabledNotifications.indexOf(3) !== -1} - onChange={ev => this.setNotificationEnabled(3, ev.target.checked)} - />{" "} - Other answer to same question - </div> - </div> - {notifications.map(notification => ( - <NotificationComponent - notification={notification} - key={notification.oid} - /> - ))} - <div> - {notifications.length < this.state.notifications.length && ( - <button - onClick={() => this.setState({ showAllNotifications: true })} - > - Show {this.state.notifications.length - notifications.length} More - Notifications - </button> - )} - {!this.state.showReadNotifications && ( - <button onClick={this.loadAllNotifications}> - Show Read Notifications - </button> - )} - {this.state.notifications.filter(notification => !notification.read) - .length > 0 && ( - <button onClick={this.markAllRead}>Mark All Read</button> - )} - </div> - </div> - ); - }; - - renderAnswers = () => { - let answers = this.state.answers; - if (!this.state.showAllAnswers && answers.length > 5) { - answers = answers.slice(0, 5); - } - return ( - <div {...styles.rowContent}> - <h2>Answers</h2> - {answers.map(answer => ( - <AnswerComponent - key={answer.oid} - isReadonly={true} - isAdmin={this.props.isAdmin} - isExpert={false} - filename={answer.filename} - sectionId={answer.sectionId} - answer={answer} - onSectionChanged={() => undefined} - onCancelEdit={() => undefined} - /> - ))} - <div> - {answers.length < this.state.answers.length && ( - <button onClick={() => this.setState({ showAllAnswers: true })}> - Show {this.state.answers.length - answers.length} More Answers - </button> - )} - {answers.length === 0 && ( - <span>This user did not write any answers yet.</span> - )} - </div> - </div> - ); - }; - - render() { - return ( - <div {...styles.wrapper}> - {this.state.error && <div>{this.state.error}</div>} - {this.renderScoreCard()} - {(this.state.payments.length > 0 || this.props.isAdmin) && - this.renderPayments()} - <div {...styles.multiRows}> - {this.renderAnswers()} - {this.props.isMyself && this.renderNotifications()} - </div> - </div> - ); - } -} diff --git a/frontend/src/pdf/canvas-factory.ts b/frontend/src/pdf/canvas-factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1121678e978d8364c13b3e746b27e8703e1b267 --- /dev/null +++ b/frontend/src/pdf/canvas-factory.ts @@ -0,0 +1,95 @@ +import { CanvasObject } from "./utils"; +/** + * A CanvasFactory that is compatible with pdf-js but reuses old `CanvasObjects`. + * Pass an instance to pdf-js while rendering. Each instance manages a collection of + * `CanvasObject`s that "belong" to that instance. Passing an unrelated `CanvasObject` + * is undefined behavior and will most likely result in no action. + */ +export class CanvasFactory { + private canvasArray: Array<CanvasObject> = []; + private objectIndexMap: Map<CanvasObject, number> = new Map(); + private free: Set<number> = new Set(); + /** + * Return an index of a free `CanvasObject` if one is available. + */ + private getFreeIndex(): number | undefined { + // The for loop stops at the first iteration but this saves us from + // converting the iterator to an array + for (const index of this.free) return index; + return undefined; + } + /** + * Returns a free `CanvasObject` or creates a new one if none is free. + * If you don't pass width and height the caller is responsible for setting + * them and this method is allowed to return a `CanvasObject` of arbitrary size. + * @param width + * @param height + */ + create(width: number | undefined, height: number | undefined) { + const index = this.getFreeIndex(); + if (index !== undefined) { + const obj = this.canvasArray[index]; + this.free.delete(index); + // We only need to clear when the size doesn't change otherwise + // the content is cleared automatically + if ( + (width === undefined || width === obj.canvas.width) && + (height === undefined || height === obj.canvas.height) + ) { + const context = obj.context; + context.clearRect(0, 0, obj.canvas.width, obj.canvas.height); + } + if (width) obj.canvas.width = width; + if (height) obj.canvas.height = height; + return obj; + } else { + // It looks like we have to create a new instance... + const canvas = document.createElement("canvas"); + if (width) canvas.width = width; + if (height) canvas.height = height; + const context = canvas.getContext("2d"); + if (context === null) throw new Error("Could not create canvas context."); + const obj = { canvas, context }; + this.canvasArray.push(obj); + this.objectIndexMap.set(obj, this.canvasArray.length - 1); + return obj; + } + } + /** + * This is the pdf-js interface for reusing `CanvasObject`s + * @param obj + * @param width + * @param height + */ + reset(obj: CanvasObject, width: number, height: number) { + if (!obj.canvas) { + throw new Error("Canvas is not specified"); + } + if (width <= 0 || height <= 0) { + throw new Error("Invalid canvas size"); + } + obj.canvas.width = width; + obj.canvas.height = height; + } + /** + * Call this method if you no longer need the specified `CanvasObject`. + * Does nothing if the `CanvasObject`is not managed by this instance of + * `CanvasFactory`. + * @param obj + */ + public destroy(obj: CanvasObject) { + if (!obj.canvas) { + throw new Error("Canvas is not specified"); + } + obj.canvas.height = 0; + obj.canvas.width = 0; + const index = this.objectIndexMap.get(obj); + if (index === undefined) return; + this.free.add(index); + } +} +/** + * The global canvas factory instance. You should use this one if you don't have + * any good reason not to use it. + */ +export const globalFactory = new CanvasFactory(); diff --git a/frontend/src/pdf/pdf-renderer.ts b/frontend/src/pdf/pdf-renderer.ts new file mode 100644 index 0000000000000000000000000000000000000000..2db71ad9e7ac8ee47410386bba2adaaf952f5074 --- /dev/null +++ b/frontend/src/pdf/pdf-renderer.ts @@ -0,0 +1,359 @@ +import pdfjs, { + PDFDocumentProxy, + PDFPageProxy, + PDFPromise, + TextContent, +} from "./pdfjs"; +import { globalFactory } from "./canvas-factory"; +import { + PdfCanvasReference, + PdfCanvasReferenceManager, +} from "./reference-counting"; +import { CanvasObject } from "./utils"; + +interface MainCanvasPageLoadedData { + width: number; + height: number; +} +/** + * Each page has one main canvas that pdf-js renders to. This interface + * describes the data that is associated with such a canvas. Please notice + * that is not guaranteed that the content is rendered until the `rendered` + * Promie is resolved. The `pageLoaded` promise is guaranteed to always resolve + * before `rendered` is resolved. + */ +interface MainCanvas { + scale: number; + currentMainRef: PdfCanvasReference | undefined; + canvasObject: CanvasObject; + referenceManager: PdfCanvasReferenceManager; + pageLoaded: Promise<MainCanvasPageLoadedData>; + rendered: Promise<void>; +} +/** + * The PDF class represents our rendering layer on top of pdf-js. It's a renderer + * that only renders its `PDFDocumentProxy`. Loading a `PDFDocumentProxy` + * is the responsibility of the caller. + */ +export default class PDF { + document: PDFDocumentProxy; + private pageMap: Map<number, PDFPromise<PDFPageProxy>> = new Map(); + // SVGs aren't mentioned in pdf-js types :( + // tslint:disable-next-line: no-any + private operatorListMap: Map<number, PDFPromise<any[]>> = new Map(); + // tslint:disable-next-line: no-any + private gfxMap: Map<number, any> = new Map(); + private svgMap: Map<number, SVGElement> = new Map(); + private embedFontsSvgMap: Map<number, SVGElement> = new Map(); + private textMap: Map<number, PDFPromise<TextContent>> = new Map(); + /** + * Each `Set` once set shouldn't change anymore as it saves us from having to lookup + * again. You therefore need to clear each set if you want to remove all references. + */ + private mainCanvasMap: Map<number, Set<MainCanvas>> = new Map(); + constructor(document: PDFDocumentProxy) { + this.document = document; + } + async getPage(pageNumber: number): Promise<PDFPageProxy> { + const cachedPage = this.pageMap.get(pageNumber); + if (cachedPage !== undefined) return cachedPage; + + const loadedPage = this.document.getPage(pageNumber); + this.pageMap.set(pageNumber, loadedPage); + return loadedPage; + } + // tslint:disable-next-line: no-any + private async getOperatorList(pageNumber: number): Promise<any[]> { + const cachedOperatorList = this.operatorListMap.get(pageNumber); + if (cachedOperatorList !== undefined) return cachedOperatorList; + const page = await this.getPage(pageNumber); + // tslint:disable-next-line: no-any + const operatorList = (page as any).getOperatorList(); + this.operatorListMap.set(pageNumber, operatorList); + return operatorList; + } + // tslint:disable-next-line: no-any + private async getGfx(pageNumber: number): Promise<any> { + const cachedGfx = this.gfxMap.get(pageNumber); + if (cachedGfx !== undefined) return cachedGfx; + + const page = await this.getPage(pageNumber); + // tslint:disable-next-line: no-any + const gfx = new (pdfjs as any).SVGGraphics( + // tslint:disable-next-line: no-any + (page as any).commonObjs, + // tslint:disable-next-line: no-any + (page as any).objs, + ); + this.gfxMap.set(pageNumber, gfx); + return gfx; + } + /** + * Renders the page `pageNumber` to an SVGElement. The returned instance will + * be unique and the caller is free to mount it anywhere in the tree. + * @param pageNumber + * @param embedFonts Wheter the fonts should be embedded into the SVG + */ + async renderSvg( + pageNumber: number, + embedFonts: boolean = false, + ): Promise<SVGElement> { + if (embedFonts) { + const cachedSvg = this.embedFontsSvgMap.get(pageNumber); + if (cachedSvg !== undefined) + return cachedSvg.cloneNode(true) as SVGElement; + } else { + const cachedSvg = this.svgMap.get(pageNumber); + if (cachedSvg !== undefined) + return cachedSvg.cloneNode(true) as SVGElement; + } + const page = await this.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1 }); + const operatorList = await this.getOperatorList(pageNumber); + const gfx = await this.getGfx(pageNumber); + gfx.embedFonts = embedFonts; + const element = await gfx.getSVG(operatorList, viewport); + if (embedFonts) { + this.embedFontsSvgMap.set(pageNumber, element); + } else { + this.svgMap.set(pageNumber, element); + } + + return element; + } + /** + * Renders the page `pageNumber` to `canvasObject` with a scale of + * `scale`. Creates a reference using `referenceManager` that is active + * until rendering is finished. It expects that `canvasObject` is not + * reused until the reference is released. The method also sets the + * `width` and `height` of the `canvasObject`. + * @param referenceManager + * @param canvasObject + * @param pageNumber + * @param scale + * @returns two promises: + * [0]: when the page is loaded, + * [1]: when the page is rendered + */ + renderCanvas( + referenceManager: PdfCanvasReferenceManager, + canvasObject: CanvasObject, + pageNumber: number, + scale: number, + ): [Promise<void>, Promise<void>] { + const renderingReference = referenceManager.createRetainedRef(); + const pagePromise = this.getPage(pageNumber); + const renderingPromise = (async () => { + const page = await pagePromise; + const viewport = page.getViewport({ scale }); + canvasObject.canvas.width = viewport.width; + canvasObject.canvas.height = viewport.height; + canvasObject.canvas.style.width = "100%"; + canvasObject.canvas.style.height = "100%"; + // we need the `as unknown as any` because the types don't specify the + // `canvasFactory` and typescript would complain. + await page.render(({ + canvasContext: canvasObject.context, + viewport, + canvasFactory: globalFactory, + } as unknown) as any).promise; + renderingReference.release(); + })(); + + return [ + (async () => { + await pagePromise; + })(), + renderingPromise, + ]; + } + /** + * Creates a new mainCanvas for `pageNumber` and renders the page to it. + * The `MainCanvas` object contains all the necessary data. + * @param pageNumber + * @param scale + */ + private createMainCanvas(pageNumber: number, scale: number): MainCanvas { + const canvasObject = globalFactory.create(undefined, undefined); + const referenceManager = new PdfCanvasReferenceManager(0); + const initialRef = referenceManager.createRetainedRef(); + const [loadPromise, renderingPromise] = this.renderCanvas( + referenceManager, + canvasObject, + pageNumber, + scale, + ); + const mainCanvas: MainCanvas = { + scale, + currentMainRef: initialRef, + canvasObject, + referenceManager, + pageLoaded: (async () => { + await loadPromise; + return { + width: canvasObject.canvas.width, + height: canvasObject.canvas.height, + }; + })(), + rendered: renderingPromise, + }; + // Add the mainCanvas to the correct set + const existingSet = this.mainCanvasMap.get(pageNumber); + const newSet = new Set([mainCanvas]); + const mainCanvasSet = existingSet || newSet; + if (existingSet) { + existingSet.add(mainCanvas); + } else { + this.mainCanvasMap.set(pageNumber, newSet); + } + // Remove it if we no longer need it + let timeout: number | undefined; + initialRef.addListener(() => { + mainCanvas.currentMainRef = undefined; + }); + referenceManager.addListener((cnt: number) => { + if (cnt <= 0) { + // We keep the mainCanvas around a bit so that it can be reused. + // 10_000 turned out to be a decent value. + timeout = window.setTimeout(() => { + globalFactory.destroy(canvasObject); + mainCanvasSet.delete(mainCanvas); + }, 10000); + } else { + // If the reference is used again we abort its removal. + if (timeout) window.clearTimeout(timeout); + timeout = undefined; + } + }); + return mainCanvas; + } + /** + * Renders `pageNumber` from `start` to `end` using at least `scale`. + * It returns a promise that resolves when the content is rendered. The method + * can either return a main canvas or just the specified section. If a main + * canvas is returned you are responsible for aligning it correctly. It assumes + * that scaling a canvas down doesn't reduce the quality. You are also responsible + * for releasing the retained reference that gets returned. + * @param pageNumber + * @param scale Minimum scale + * @param start Relative y-start on page + * @param end Relative y-end on page + * @returns A promise resolving to an array: + * [0]: The canvas, + * [1]: Wether the canvas is a main canvas, + * [2]: The reference you have to release if you no longer need the canvas. + */ + async renderCanvasSplit( + pageNumber: number, + scale: number, + start: number, + end: number, + ): Promise<[HTMLCanvasElement, boolean, PdfCanvasReference]> { + const mainCanvasSet = this.mainCanvasMap.get(pageNumber); + let mainCanvas: MainCanvas | undefined; + let isMainUser = false; + if (mainCanvasSet) { + for (const existingMainCanvas of mainCanvasSet) { + if ( + existingMainCanvas.scale + 0.001 >= scale && + // It might be possible that there is a main canvas that is suitable + // and currently has no use. Prefer to use that one instead. + (mainCanvas === undefined || mainCanvas.currentMainRef !== undefined) + ) { + mainCanvas = existingMainCanvas; + } + } + // Did we find a main canvas that isn't used? + if (mainCanvas && mainCanvas.currentMainRef === undefined) { + isMainUser = true; + mainCanvas.currentMainRef = mainCanvas?.referenceManager.createRetainedRef(); + } + } + // It looks like we have to render from scratch + if (mainCanvas === undefined) { + mainCanvas = this.createMainCanvas(pageNumber, scale); + isMainUser = true; + } + // This isn't possible but it's hard to tell typescript that it is not + // possible. + if (mainCanvas === undefined) throw new Error(); + const ref = isMainUser + ? mainCanvas.currentMainRef! + : mainCanvas.referenceManager.createRetainedRef(); + + if (isMainUser) { + ref.addListener(() => { + // Typescript still thinks that mainCanvas could be undefined... + if (mainCanvas === undefined) throw new Error(); + mainCanvas.currentMainRef = undefined; + }); + // Wait until rendering is finished (needed for snap location detection) + await mainCanvas.rendered; + return [mainCanvas.canvasObject.canvas, true, ref]; + } else { + const mainRef = mainCanvas.referenceManager.createRetainedRef(); + // It should also be possible to await mainCanvas.pageLoaded first + // but it doesn't really matter. + const [pageSize, page] = await Promise.all([ + mainCanvas.pageLoaded, + this.getPage(pageNumber), + ]); + const viewport = page.getViewport({ scale }); + const width = viewport.width; + const height = viewport.height * (end - start); + const obj = globalFactory.create(width, height); + const newManager = new PdfCanvasReferenceManager(0); + const childRef = newManager.createRetainedRef(); + obj.canvas.style.width = "100%"; + obj.canvas.style.height = "100%"; + //source + const [sx, sy, sw, sh] = [ + 0, + pageSize.height * start, + pageSize.width, + (end - start) * pageSize.height, + ]; + // destination + const [dx, dy, dw, dh] = [0, 0, width, height]; + const renderingReference = newManager.createRetainedRef(); + mainCanvas.rendered.then(() => { + const ctx = obj.context; + if (ctx === null) throw new Error("Redering failed."); + if (mainCanvas === undefined) throw new Error(); + ctx.drawImage( + mainCanvas.canvasObject.canvas, + sx, + sy, + sw, + sh, + dx, + dy, + dw, + dh, + ); + renderingReference.release(); + }); + + newManager.addListener((cnt: number) => { + if (cnt <= 0) { + mainRef.release(); + globalFactory.destroy(obj); + } + }); + + return [obj.canvas, false, childRef]; + } + } + /** + * Renders the text layer of the specified `pageNumber` + * @param pageNumber + */ + async renderText(pageNumber: number): Promise<TextContent> { + const cachedPromise = this.textMap.get(pageNumber); + if (cachedPromise !== undefined) return cachedPromise; + const page = await this.getPage(pageNumber); + const contentPromise = page.getTextContent(); + this.textMap.set(pageNumber, contentPromise); + return contentPromise; + } +} diff --git a/frontend/src/pdf/pdf-section-canvas.tsx b/frontend/src/pdf/pdf-section-canvas.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09983ed52cb1414869f47e128325d2a1b3e424ad --- /dev/null +++ b/frontend/src/pdf/pdf-section-canvas.tsx @@ -0,0 +1,266 @@ +import { useInViewport } from "@umijs/hooks"; +import { Card } from "@vseth/components"; +import { css, cx } from "emotion"; +import * as React from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; +import { DebugContext } from "../components/Debug"; +import IconButton from "../components/icon-button"; +import PdfSectionCanvasOverlay from "../components/pdf-section-canvas-overlay"; +import PdfSectionText from "../components/pdf-section-text"; +import useAlmostInViewport from "../hooks/useAlmostInViewport"; +import useDpr from "../hooks/useDpr"; +import PDF from "./pdf-renderer"; +import { PdfCanvasReference } from "./reference-counting"; + +const lastSection = css` + margin-bottom: 2rem; +`; + +const usePdf = ( + shouldRender: boolean, + renderer: PDF, + pageNumber: number, + start: number, + end: number, + scale: number | undefined, +): [ + HTMLCanvasElement | null, + number[] | undefined, + number, + number, + boolean, +] => { + const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>( + null, + ); + const [view, setView] = useState<number[]>(); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [isMainCanvas, setIsMainCanvas] = useState(false); + useEffect(() => { + if (shouldRender) { + let cancel = false; + let canvasRef: PdfCanvasReference | undefined; + let currentPromise: + | Promise<[HTMLCanvasElement, boolean, PdfCanvasReference]> + | undefined; + (async () => { + const page = await renderer.getPage(pageNumber); + if (cancel) return; + setView(page.view); + const viewport = page.getViewport({ scale: 1.0 }); + setWidth(viewport.width); + setHeight(viewport.height); + if (scale === undefined) { + return; + } + currentPromise = renderer.renderCanvasSplit( + pageNumber, + scale, + start, + end, + ); + const [canvas, isMain, ref] = await currentPromise; + canvasRef = ref; + if (cancel) return; + setIsMainCanvas(isMain); + setCanvasElement(canvas); + })(); + return () => { + cancel = true; + setCanvasElement(null); + + if (canvasRef) canvasRef.release(); + else if (currentPromise) { + currentPromise.then(([, , newRef]) => newRef.release()); + } + }; + } + return () => undefined; + }, [shouldRender, renderer, pageNumber, scale, start, end]); + return [canvasElement, view, width, height, isMainCanvas]; +}; + +interface Props { + oid: string | undefined; + page: number; + start: number; + end: number; + renderer: PDF; + hidden?: boolean; + targetWidth?: number; + onVisibleChange?: (newVisible: boolean) => void; + onAddCut?: (pos: number) => void; + addCutText?: string; + snap?: boolean; + displayHideShowButtons?: boolean; + onSectionHiddenChange?: ( + section: string | [number, number], + newState: boolean, + ) => void; +} +const PdfSectionCanvas: React.FC<Props> = React.memo( + ({ + oid, + page, + start, + end, + renderer, + + hidden = false, + targetWidth = 300, + onVisibleChange, + onAddCut, + addCutText, + snap = true, + displayHideShowButtons = false, + onSectionHiddenChange = () => {}, + }) => { + const relativeHeight = end - start; + + const { displayCanvasType } = useContext(DebugContext); + const [visible, containerElement] = useAlmostInViewport<HTMLDivElement>(); + const [containerHeight, setContainerHeight] = useState(0); + const [translateY, setTranslateY] = useState(0); + const [currentScale, setCurrentScale] = useState<number | undefined>( + undefined, + ); + const toggleVisibility = useCallback( + () => onSectionHiddenChange(oid ? oid : [page, end], !hidden), + [oid, page, end, hidden, onSectionHiddenChange], + ); + const dpr = useDpr(); + const [canvas, view, width, height, isMainCanvas] = usePdf( + visible || false, + renderer, + page, + start, + end, + visible ? (currentScale ? currentScale * dpr : undefined) : undefined, + ); + const [inViewport, inViewportRef] = useInViewport<HTMLDivElement>(); + const v = inViewport || false; + useEffect(() => { + if (onVisibleChange) onVisibleChange(v); + return () => { + if (onVisibleChange) { + onVisibleChange(false); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [v]); + + const canvasMountingPoint = useCallback<(element: HTMLDivElement) => void>( + element => { + if (element === null) return; + if (canvas === null) return; + if (isMainCanvas) { + canvas.style.transform = `translateY(-${translateY}px)`; + } else { + canvas.style.transform = ""; + } + while (element.firstChild) element.removeChild(element.firstChild); + element.appendChild(canvas); + }, + [canvas, translateY, isMainCanvas], + ); + + useEffect(() => { + if (width === 0) return; + const scaling = targetWidth / width; + setCurrentScale(scaling); + const newHeight = height * scaling; + setContainerHeight(relativeHeight * newHeight); + setTranslateY(start * newHeight); + if (canvas === null) return; + if (isMainCanvas) { + canvas.style.transform = `translateY(-${start * newHeight}px)`; + } else { + canvas.style.transform = ""; + } + }, [ + targetWidth, + canvas, + width, + height, + isMainCanvas, + relativeHeight, + start, + ]); + + const onAddCutHandler = (pos: number) => + onAddCut && onAddCut(start + (end - start) * (pos / containerHeight)); + + let content: React.ReactNode; + if (canvas) { + content = <div ref={canvasMountingPoint} />; + } else { + content = <div />; + } + + return ( + <Card className={end === 1 ? lastSection : undefined}> + <div ref={inViewportRef}> + <div + className="cover-container" + style={{ + width: `${targetWidth}px`, + height: `${containerHeight || + targetWidth * relativeHeight * 1.414}px`, + filter: hidden ? "contrast(0.5)" : undefined, + }} + ref={containerElement} + > + {content} + {displayCanvasType && ( + <div + className={cx( + "position-absolute", + "position-top-right", + "m-3", + "p-1", + "rounded-circle", + isMainCanvas ? "bg-success" : "bg-info", + )} + /> + )} + {displayHideShowButtons && ( + <div className="position-absolute position-top-left m-2 p1"> + <IconButton + size="sm" + icon={hidden ? "VIEW" : "VIEW_OFF"} + tooltip="Toggle visibility" + onClick={toggleVisibility} + /> + </div> + )} + {visible && ( + <PdfSectionText + page={page} + start={start} + end={end} + renderer={renderer} + scale={currentScale || 1} + view={view} + translateY={translateY} + /> + )} + {canvas && addCutText && ( + <PdfSectionCanvasOverlay + canvas={canvas} + start={start} + end={end} + isMain={isMainCanvas} + addCutText={addCutText} + onAddCut={onAddCutHandler} + snap={snap} + /> + )} + </div> + </div> + </Card> + ); + }, +); + +export default PdfSectionCanvas; diff --git a/frontend/src/pdfjs.d.ts b/frontend/src/pdf/pdfjs.d.ts similarity index 100% rename from frontend/src/pdfjs.d.ts rename to frontend/src/pdf/pdfjs.d.ts diff --git a/frontend/src/pdfjs.js b/frontend/src/pdf/pdfjs.js similarity index 54% rename from frontend/src/pdfjs.js rename to frontend/src/pdf/pdfjs.js index f1632338e3294b341032513889154581f4f5d286..6d8fe55f4c048930b056e70a4c1f2909ff3eee61 100644 --- a/frontend/src/pdfjs.js +++ b/frontend/src/pdf/pdfjs.js @@ -1,3 +1,9 @@ +/** + * By default worker-loader won't output to /static. Therefore we need to reexport + * pdfjs and inject the correct location using webpack's loader sytnax. I hope this + * is just a temporary workaround because there probably is a way to do this nicely. + * We could also overwrite the webpack config... + */ var pdfjs = require("pdfjs-dist/build/pdf.js"); // eslint-disable-next-line import/no-webpack-loader-syntax var PdfjsWorker = require("worker-loader?name=static/workers/[hash].worker.js!pdfjs-dist/build/pdf.worker.js"); diff --git a/frontend/src/pdf/reference-counting.ts b/frontend/src/pdf/reference-counting.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f72051f9b9031c1f74925de3c75c2bd3c09a9ff --- /dev/null +++ b/frontend/src/pdf/reference-counting.ts @@ -0,0 +1,45 @@ +export class PdfCanvasReference { + active: boolean; + manager: PdfCanvasReferenceManager; + private listeners: Array<() => void> = []; + constructor(manager: PdfCanvasReferenceManager) { + this.active = true; + this.manager = manager; + } + addListener(fn: () => void) { + this.listeners.push(fn); + } + release() { + if (!this.active) return; + this.manager.dec(); + for (const listener of this.listeners) listener(); + this.active = false; + } +} +export class PdfCanvasReferenceManager { + private refCount: number; + private listeners: Array<(cnt: number) => void> = []; + constructor(initialRefCount: number) { + this.refCount = initialRefCount; + } + createRetainedRef(): PdfCanvasReference { + this.inc(); + const ref = new PdfCanvasReference(this); + return ref; + } + addListener(fn: (cnt: number) => void) { + this.listeners.push(fn); + } + inc() { + this.refCount++; + for (const listener of this.listeners) { + listener(this.refCount); + } + } + dec() { + this.refCount--; + for (const listener of this.listeners) { + listener(this.refCount); + } + } +} diff --git a/frontend/src/pdf/snap.ts b/frontend/src/pdf/snap.ts new file mode 100644 index 0000000000000000000000000000000000000000..17cf36ae953c7627732f2b2e8e415d800d1ad2fd --- /dev/null +++ b/frontend/src/pdf/snap.ts @@ -0,0 +1,134 @@ +import { getPixel } from "./utils"; +/** + * A single SnapRegion can consist of multiple `snapPoints`. The coordinates are relative + * to start and end of a section. That means that a `SnapRegion(start: 0, end: 1)` in a + * section 0-0.5 would span 0-0.5 in the page coordinate system. + */ +interface SnapRegion { + start: number; + end: number; + snapPoints: number[]; +} +/** + * Given a canvas this function determines where a cut would be good. It does this + * by detecting "clean regions" - regions where every pixel in a row has the same + * color. This means that + * ``` + * rrrrr + * ggggg + * bbbbb + * ``` + * is a single "clean" region but `rgrrrrr` is not. + * + * There are two types of `SnapRegion`s: big ones and small ones. Big ones have + * snapPoint at the start and at the end suggesting that the area could be hidden. + * + * @param canvas + * @param start + * @param end + * @param isMain + * @param options + */ +export const determineOptimalCutPositions = ( + canvas: HTMLCanvasElement, + start: number, + end: number, + isMain: boolean, + { + minRegionSize = 0.01, + bigSnapRegionPadding = 0.02, + bigSnapRegionMinSize = 0.07, + } = {}, +): SnapRegion[] => { + const s: Array<SnapRegion> = []; + /** + * @param a The start of the clean region + * @param b Te end of the clean region + * @param isLast Wether the region is the last region in the section + */ + const handler = (a: number, b: number, isLast: boolean = false) => { + /** + * Size of the `SnapRegion` in the page coordinate system. + */ + const size = (b - a) * (end - start); + if (size > minRegionSize) { + const snapPoints: number[] = []; + // There is no snapPoint at the beginning of a section. But it's still a + // snapRegion we need to add. + if (a !== 0) { + if (size > bigSnapRegionMinSize) { + snapPoints.push(a + bigSnapRegionPadding / (end - start)); + if (!(isLast && end === 1)) + snapPoints.push(b - bigSnapRegionPadding / (end - start)); + } else { + if (!isLast) snapPoints.push((a + b) / 2); + } + // There is always a snapPoint at the end of each page if the last row is + // "clean". The `isLast` check shouldn't be necessary, but prevents weird + // snapPoints when end is set incorrectly. + if (isLast && end === 1) snapPoints.push(1); + s.push({ + start: a, + end: b, + snapPoints, + }); + } else { + if (size > bigSnapRegionMinSize) { + if (!(isLast && end === 1)) + snapPoints.push(b - bigSnapRegionPadding / (end - start)); + } + s.push({ + start: a, + end: b, + snapPoints, + }); + } + } + }; + const context = canvas.getContext("2d"); + if (context === null) return s; + // Determine coordinates based on wether the canvas is a main canvas. + const [sx, sy, sw, sh] = isMain + ? [ + 0, + (canvas.height * start) | 0, + canvas.width, + (canvas.height * (end - start)) | 0, + ] + : [0, 0, canvas.width, canvas.height]; + // No work to do - will always return an empty array but there is no need to create + // a new array for that. + if (sh === 0) return s; + const imageData = context.getImageData(sx, sy, sw, sh); + /** + * The section relative position at which the current clean section started. + */ + let sectionStart: number | undefined; + for (let y = 0; y < imageData.height; y++) { + if (imageData.width === 0) continue; + let clean = true; + const [rowR, rowG, rowB, rowA] = getPixel(imageData, 0, y); + for (let x = 1; x < imageData.width; x++) { + const [r, g, b, a] = getPixel(imageData, x, y); + if (r !== rowR || g !== rowG || b !== rowB || a !== rowA) { + clean = false; + break; + } + } + if (clean) { + if (sectionStart === undefined) { + sectionStart = y / imageData.height; + } + } else { + if (sectionStart !== undefined) { + handler(sectionStart, y / imageData.height); + } + sectionStart = undefined; + } + } + // If the last row is "clean" we have to add that section as well. + if (sectionStart !== undefined) { + handler(sectionStart, 1, true); + } + return s; +}; diff --git a/frontend/src/pdf/utils.ts b/frontend/src/pdf/utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..c22a174cd18f143296453676a910ce8cb6cd12e2 --- /dev/null +++ b/frontend/src/pdf/utils.ts @@ -0,0 +1,14 @@ +export interface CanvasObject { + canvas: HTMLCanvasElement; + context: CanvasRenderingContext2D; +} +export const getPixel = (imageData: ImageData, x: number, y: number) => { + const startIndex = y * (imageData.width * 4) + x * 4; + const data = imageData.data; + return [ + data[startIndex], + data[startIndex + 1], + data[startIndex + 2], + data[startIndex + 3], + ]; +}; diff --git a/frontend/src/register-service-worker.ts b/frontend/src/register-service-worker.ts deleted file mode 100644 index faf33387222bc5694c405a337197c40a3b37f9a8..0000000000000000000000000000000000000000 --- a/frontend/src/register-service-worker.ts +++ /dev/null @@ -1,114 +0,0 @@ -// tslint:disable:no-console -// In production, we register a service worker to serve assets from local cache. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on the 'N+1' visit to a page, since previously -// cached resources are updated in the background. - -// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. -// This link also includes instructions on opting out of this behavior. - -const isLocalhost = Boolean( - window.location.hostname === "localhost" || - // [::1] is the IPv6 localhost address. - window.location.hostname === "[::1]" || - // 127.0.0.1/8 is considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, - ), -); - -export default function register() { - if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL( - process.env.PUBLIC_URL!, - window.location.toString(), - ); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 - return; - } - - window.addEventListener("load", () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (!isLocalhost) { - // Is not local host. Just register service worker - registerValidSW(swUrl); - } else { - // This is running on localhost. Lets check if a service worker still exists or not. - checkValidServiceWorker(swUrl); - } - }); - } -} - -function registerValidSW(swUrl: string) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker) { - installingWorker.onstatechange = () => { - if (installingWorker.state === "installed") { - if (navigator.serviceWorker.controller) { - // At this point, the old content will have been purged and - // the fresh content will have been added to the cache. - // It's the perfect time to display a 'New content is - // available; please refresh.' message in your web app. - console.log("New content is available; please refresh."); - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // 'Content is cached for offline use.' message. - console.log("Content is cached for offline use."); - } - } - }; - } - }; - }) - .catch(error => { - console.error("Error during service worker registration:", error); - }); -} - -function checkValidServiceWorker(swUrl: string) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - if ( - response.status === 404 || - response.headers.get("content-type")!.indexOf("javascript") === -1 - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl); - } - }) - .catch(() => { - console.log( - "No internet connection found. App is running in offline mode.", - ); - }); -} - -export function unregister() { - if ("serviceWorker" in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.unregister(); - }); - } -} diff --git a/frontend/src/split-render.ts b/frontend/src/split-render.ts deleted file mode 100644 index f4cc58e257a326176584e7e610923df82ce99b02..0000000000000000000000000000000000000000 --- a/frontend/src/split-render.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { CutPosition } from "./interfaces"; -import * as pdfjs from "./pdfjs"; - -interface RenderTarget { - context: CanvasRenderingContext2D; - width: number; - height: number; -} - -interface PageProxy { - isRendered: boolean; - renderFunctions: Function[]; - page: pdfjs.PDFPageProxy; - rendered?: RenderedPage; -} - -interface RenderedPage { - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; - viewport: pdfjs.PDFPageViewport; -} - -export interface Dimensions { - width: number; - height: number; -} - -interface StartSizeRect { - x: number; - y: number; - w: number; - h: number; -} - -export class SectionRenderer { - pages: PageProxy[] = []; - - constructor( - readonly pdf: pdfjs.PDFDocumentProxy, - private targetWidth: number, - ) { - this.pdf = pdf; - } - - destroy() { - for (let i = 0; i < this.pdf.numPages; i++) { - this.freePage(i); - } - } - - renderPage(page: number) { - if (this.pages[page].isRendered) { - this.freePage(page); - } - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - if (!context) { - throw new Error("failed to create context"); - } - const pdfpage = this.pages[page].page; - - // Create viewport with scale 1 that can be used to calculate the required scaling factor - const { width } = pdfpage.getViewport({ scale: 1 }); - const scale = this.targetWidth / width; - const viewport = pdfpage.getViewport({ scale }); - canvas.width = viewport.width; - canvas.height = viewport.height; - - pdfpage - .render({ - canvasContext: context, - viewport, - }) - .promise.then(() => { - this.pages[page].isRendered = true; - this.pages[page].rendered = { - canvas, - context, - viewport, - }; - this.pages[page].renderFunctions.forEach(fct => { - fct(); - }); - }); - } - - freePage(page: number) { - const rendered = this.pages[page].rendered; - if (rendered) { - rendered.canvas.width = 0; - rendered.canvas.height = 0; - rendered.canvas.remove(); - this.pages[page].rendered = undefined; - this.pages[page].isRendered = false; - } - } - - setTargetWidth(width: number) { - if (width !== this.targetWidth) { - this.targetWidth = width; - this.pages.forEach((page, idx) => { - if (page.renderFunctions.length > 0) { - this.renderPage(idx); - } else { - this.freePage(idx); - } - }); - } - } - - addVisible(start: CutPosition, renderFunction: Function) { - const page = start.page - 1; - this.pages[page].renderFunctions.push(renderFunction); - if ( - this.pages[page].renderFunctions.length === 1 && - !this.pages[page].isRendered - ) { - this.renderPage(page); - } - } - - removeVisible(start: CutPosition, renderFunction: Function) { - const page = start.page - 1; - this.pages[page].renderFunctions = this.pages[page].renderFunctions.filter( - fct => fct !== renderFunction, - ); - /* - It seems like we can not save much memory, but the CPU usage is much higher if we destroy stuff. - if (this.pages[page].renderFunctions.length === 0) { - this.freePage(page); - } - */ - } - - // calculate the size in the source document of the given section (start <-> end) - static sourceDimensions( - viewport: pdfjs.PDFPageViewport, - start: CutPosition, - end: CutPosition, - ): StartSizeRect { - const { width: w, height: h } = viewport; - return { - x: Math.floor(0), - y: Math.floor(h * start.position), - w: Math.floor(w), - h: Math.floor(h * (end.position - start.position)), - }; - } - - // calculate the required size of the canvas which is going to render a section (start <-> end) - sectionDimensions( - start: CutPosition, - end: CutPosition, - width: number, - ): Dimensions { - const page = this.pages[start.page - 1].page; - const src = SectionRenderer.sourceDimensions( - page.getViewport({ scale: 1 }), - start, - end, - ); - return { width, height: (src.h / src.w) * width }; - } - - render(target: RenderTarget, start: CutPosition, end: CutPosition) { - const page = start.page - 1; - const rendered = this.pages[page].rendered; - if (!rendered) { - return false; - } - const src = SectionRenderer.sourceDimensions(rendered.viewport, start, end); - const dst = { - x: 0, - y: 0, - w: target.width, - h: target.height, - }; - target.context.drawImage( - rendered.canvas, - src.x, - src.y, - src.w, - src.h, - dst.x, - dst.y, - dst.w, - dst.h, - ); - return true; - } - - renderTextLayer( - target: HTMLDivElement, - canvas: HTMLCanvasElement, - start: CutPosition, - end: CutPosition, - dpr: number, - ) { - const page = start.page - 1; - const pdfpage = this.pages[page].page; - - // Locations of text divs are not scaled; only the canvas is scaled by dpr and then resized down again by dpr - // via a style element. targetWidth is the size of the canvas, therefore we must resize the locations of the OCR - // divs: scale down by dpr (divide by dpr) - - // Create viewport with scale 1 that can be used to calculate the required scaling factor - const { width } = pdfpage.getViewport({ scale: 1 }); - const scale = this.targetWidth / width / dpr; - const viewport = pdfpage.getViewport({ scale }); - - const src = SectionRenderer.sourceDimensions(viewport, start, end); - target.innerHTML = ""; - pdfpage.getTextContent().then(texts => { - // tslint:disable-next-line:no-any - const PDFJS = pdfjs as any; - const divs: HTMLElement[] = []; - PDFJS.renderTextLayer({ - textContent: texts, - container: target, - viewport: viewport, - textDivs: divs, - }).promise.then(() => { - divs.forEach(div => { - const top = parseFloat(div.style.top || "0"); - if (top < src.y || src.y + src.h < top) { - if (div.parentElement) { - div.parentElement.removeChild(div); - } - } else { - div.style.top = top - src.y + "px"; - } - }); - }); - }); - } - - /** - * Optimize the position of the cut. If the line of the cut is "clean", it will try to have a - * margin of 20 px to the closest text. If this is not possible, it will be placed in the middle. - * If the cut position is "dirty", the next clean sections to the top and bottom will be located - * and the larger of them will be taken. It is then handled as if the click was in this section. - */ - optimizeCutPosition(page: number, relHeight: number): number { - const rendered = this.pages[page].rendered; - if (!this.pages[page].isRendered || !rendered) { - return relHeight; - } - const width = rendered.canvas.width; - const height = rendered.canvas.height; - const clickedy = Math.ceil(height * relHeight); - const desiredMargin = width / 60; - - const isPure = (y: number) => { - const line = rendered.context.getImageData(0, y, width, 1).data; - for (let i = 0; i < 4 * width; i++) { - if (line[i] !== 255) { - return false; - } - } - return true; - }; - - let topPure = 0; - let botPure = 0; - - if (isPure(clickedy)) { - topPure = clickedy; - botPure = clickedy; - while (topPure > 0 && isPure(topPure - 1)) { - topPure--; - } - while (botPure < height - 1 && isPure(botPure + 1)) { - botPure++; - } - } else { - let topBotPure = clickedy - 1; - let botTopPure = clickedy + 1; - while (topBotPure > 0 && !isPure(topBotPure)) { - topBotPure--; - } - while (botTopPure < height - 1 && !isPure(botTopPure)) { - botTopPure++; - } - let topTopPure = topBotPure; - let botBotPure = botTopPure; - - while (topTopPure > 0 && isPure(topTopPure - 1)) { - topTopPure--; - } - while (botBotPure < height - 1 && isPure(botBotPure + 1)) { - botBotPure++; - } - - if (topBotPure === topTopPure && botBotPure === botTopPure) { - return relHeight; - } - - if (topBotPure - topTopPure > botBotPure - botTopPure) { - topPure = topTopPure; - botPure = topBotPure; - } else { - topPure = botTopPure; - botPure = botBotPure; - } - } - - if (botPure - topPure < 2 * desiredMargin) { - return (botPure + topPure) / 2 / height; - } else { - return (topPure + desiredMargin) / height; - } - } -} - -export async function createSectionRenderer( - pdf: pdfjs.PDFDocumentProxy, - targetWidth: number, -): Promise<SectionRenderer> { - const renderer = new SectionRenderer(pdf, targetWidth); - renderer.pages = []; - for (let i = 0; i < pdf.numPages; i++) { - renderer.pages.push({ - isRendered: false, - renderFunctions: [], - page: await pdf.getPage(i + 1), - }); - } - return renderer; -} diff --git a/frontend/src/ts-utils.ts b/frontend/src/ts-utils.ts deleted file mode 100644 index ffbc85f665b0c931fa9d01c714fda469df48a9f7..0000000000000000000000000000000000000000 --- a/frontend/src/ts-utils.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type KeysWhereValue<T, S> = { - [K in keyof T]: T[K] extends S ? K : never; -}[keyof T]; diff --git a/frontend/src/category-utils.tsx b/frontend/src/utils/category-utils.tsx similarity index 65% rename from frontend/src/category-utils.tsx rename to frontend/src/utils/category-utils.tsx index 49d83a7cc8b351cc59cbeed77a13644083c238a3..03fb958d3513a62af437b98c3fd6a001eb8cf388 100644 --- a/frontend/src/category-utils.tsx +++ b/frontend/src/utils/category-utils.tsx @@ -4,7 +4,8 @@ import { CategoryMetaDataOverview, MetaCategory, MetaCategoryWithCategories, -} from "./interfaces"; +} from "../interfaces"; +import { getCookie } from "../api/fetch-utils"; export function filterMatches(filter: string, name: string): boolean { const nameLower = name.replace(/\s/g, "").toLowerCase(); @@ -86,3 +87,40 @@ export function getMetaCategoriesForCategory( })) .filter(meta1 => meta1.meta2.length > 0); } +export const mapExamsToExamType = (exams: CategoryExam[]) => { + return [ + ...exams + .reduce((map, exam) => { + const examtype = exam.examtype ?? "Exams"; + const arr = map.get(examtype); + if (arr) { + arr.push(exam); + } else { + map.set(examtype, [exam]); + } + return map; + }, new Map<string, CategoryExam[]>()) + .entries(), + ].sort(([a], [b]) => a.localeCompare(b)); +}; +export const dlSelectedExams = (selectedExams: Set<string>) => { + const form = document.createElement("form"); + form.action = "/api/exam/zipexport/"; + form.method = "POST"; + form.target = "_blank"; + for (const filename of selectedExams) { + const input = document.createElement("input"); + input.name = "filenames"; + input.value = filename; + form.appendChild(input); + } + const csrf = document.createElement("input"); + csrf.name = "csrfmiddlewaretoken"; + csrf.value = getCookie("csrftoken") || ""; + form.appendChild(csrf); + + form.style.display = "none"; + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); +}; diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..72275ac4bc901cdd43cdd02978eda3037d4c4145 --- /dev/null +++ b/frontend/src/utils/clipboard.ts @@ -0,0 +1,8 @@ +export const copy = (text: string) => { + const textarea = document.createElement("textarea"); + textarea.innerText = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); +}; diff --git a/frontend/src/utils/exam-utils.ts b/frontend/src/utils/exam-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..5af6afa3ff3d035b341d918c87e6c3d4a686c1d7 --- /dev/null +++ b/frontend/src/utils/exam-utils.ts @@ -0,0 +1,17 @@ +import moment from "moment"; +import GlobalConsts from "../globalconsts"; +import { CategoryExam } from "../interfaces"; + +export const hasValidClaim = (exam: CategoryExam) => { + if (exam.import_claim !== null && exam.import_claim_time !== null) { + if ( + moment().diff( + moment(exam.import_claim_time, GlobalConsts.momentParseString), + ) < + 4 * 60 * 60 * 1000 + ) { + return true; + } + } + return false; +}; diff --git a/frontend/src/utils/ts-utils.ts b/frontend/src/utils/ts-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1adf2d4c3694b88523e5a463eb17ddf8ae23a7ba --- /dev/null +++ b/frontend/src/utils/ts-utils.ts @@ -0,0 +1,26 @@ +export type KeysWhereValue<T, S> = { + [K in keyof T]: T[K] extends S ? K : never; +}[keyof T]; + +interface Options { + [a: string]: string; +} +type OptionsResult<T> = { + [key in keyof T]: { value: key; label: T[key] }; +}; +export const fromEntries = <T extends Array<[string, unknown]>>(o: T) => + o.reduce((prev: any, curr) => { + prev[curr[0]] = curr[1]; + return prev; + }, {}); + +export const createOptions = <T extends Options>(o: T) => + (fromEntries( + Object.entries(o).map(([key, value]) => [ + key, + { value: key, label: value }, + ]) as Array<[string, unknown]>, + ) as unknown) as OptionsResult<T>; +export type SelectOption<T> = { value: keyof T; label: string }; +export const options = <T>(map: OptionsResult<T>) => + Object.values(map) as Array<{ value: keyof T; label: string }>; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index f2850b71613ed26b0fd526160a1a452b1f06f5bc..cca5b12ed5fa3dff409d3f381034e7c6c24e662c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,25 +1,24 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "noImplicitAny": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react" + "jsx": "react", + "downlevelIteration": true }, - "include": [ - "src" - ] + "include": ["src"] } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 704d66a25f900e988e1c5dedd276087e5d5dce09..53391177b274558547b701194e4e83315983bfc2 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -745,12 +745,34 @@ "@babel/helper-plugin-utils" "^7.8.3" "@babel/plugin-transform-typescript" "^7.8.3" +"@babel/runtime-corejs2@^7.4.5": + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.8.7.tgz#5c6afcb33ef12fa1f8db6b915ff6b5ecaf6afb11" + integrity sha512-R8zbPiv25S0pGfMqAr55dRRxWB8vUeo3wicI4g9PFVBKmsy/9wmQUV1AaYW/kxRHUhx42TTh6F0+QO+4pwfYWg== + dependencies: + core-js "^2.6.5" + regenerator-runtime "^0.13.4" + "@babel/runtime@7.8.4", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.0", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.6.2", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.3": version "7.8.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.4.tgz#d79f5a2040f7caa24d53e563aad49cbc05581308" dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.2.0": + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d" + integrity sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg== + dependencies: + regenerator-runtime "^0.13.4" + +"@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.9.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.2.tgz#d90df0583a3a252f09aaa619665367bae518db06" + integrity sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.4.0", "@babel/template@^7.8.3": version "7.8.3" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" @@ -796,6 +818,18 @@ version "10.1.0" resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" +"@emotion/babel-utils@^0.6.4": + version "0.6.10" + resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc" + integrity sha512-/fnkM/LTEp3jKe++T0KyTszVGWNKPNOUJfjNKLO17BzQ6QPxgbg3whayom1Qr2oLFH3V92tDymU+dT5q676uow== + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/serialize" "^0.9.1" + convert-source-map "^1.5.1" + find-root "^1.1.0" + source-map "^0.7.2" + "@emotion/cache@^10.0.27": version "10.0.29" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" @@ -806,16 +840,54 @@ "@emotion/utils" "0.11.3" "@emotion/weak-memoize" "0.2.5" +"@emotion/core@^10.0.28": + version "10.0.28" + resolved "https://registry.yarnpkg.com/@emotion/core/-/core-10.0.28.tgz#bb65af7262a234593a9e952c041d0f1c9b9bef3d" + integrity sha512-pH8UueKYO5jgg0Iq+AmCLxBsvuGtvlmiDCOuv8fGNYn3cowFpLN98L8zO56U0H1PjDIyAlXymgL3Wu7u7v6hbA== + dependencies: + "@babel/runtime" "^7.5.5" + "@emotion/cache" "^10.0.27" + "@emotion/css" "^10.0.27" + "@emotion/serialize" "^0.11.15" + "@emotion/sheet" "0.9.4" + "@emotion/utils" "0.11.3" + +"@emotion/css@^10.0.27": + version "10.0.27" + resolved "https://registry.yarnpkg.com/@emotion/css/-/css-10.0.27.tgz#3a7458198fbbebb53b01b2b87f64e5e21241e14c" + integrity sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw== + dependencies: + "@emotion/serialize" "^0.11.15" + "@emotion/utils" "0.11.3" + babel-plugin-emotion "^10.0.27" + "@emotion/hash@0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== +"@emotion/hash@^0.6.2", "@emotion/hash@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.6.6.tgz#62266c5f0eac6941fece302abad69f2ee7e25e44" + integrity sha512-ojhgxzUHZ7am3D2jHkMzPpsBAiB005GF5YU4ea+8DNPybMk01JJUM9V9YRlF/GE95tcOm8DxQvWA2jq19bGalQ== + +"@emotion/is-prop-valid@0.8.8": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + "@emotion/memoize@0.7.4": version "0.7.4" resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== +"@emotion/memoize@^0.6.1", "@emotion/memoize@^0.6.6": + version "0.6.6" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.6.6.tgz#004b98298d04c7ca3b4f50ca2035d4f60d2eed1b" + integrity sha512-h4t4jFjtm1YV7UirAFuSuFGyLa+NNxjdkq6DpFLANNQY5rHueFZHVY+8Cu1HYVP6DrheB0kv4m5xPjo7eKT7yQ== + "@emotion/serialize@^0.11.15", "@emotion/serialize@^0.11.16": version "0.11.16" resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.11.16.tgz#dee05f9e96ad2fb25a5206b6d759b2d1ed3379ad" @@ -827,26 +899,69 @@ "@emotion/utils" "0.11.3" csstype "^2.5.7" +"@emotion/serialize@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-0.9.1.tgz#a494982a6920730dba6303eb018220a2b629c145" + integrity sha512-zTuAFtyPvCctHBEL8KZ5lJuwBanGSutFEncqLn/m9T1a6a93smBStK+bZzcNPgj4QS8Rkw9VTwJGhRIUVO8zsQ== + dependencies: + "@emotion/hash" "^0.6.6" + "@emotion/memoize" "^0.6.6" + "@emotion/unitless" "^0.6.7" + "@emotion/utils" "^0.8.2" + "@emotion/sheet@0.9.4": version "0.9.4" resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-0.9.4.tgz#894374bea39ec30f489bbfc3438192b9774d32e5" integrity sha512-zM9PFmgVSqBw4zL101Q0HrBVTGmpAxFZH/pYx/cjJT5advXguvcgjHFTCaIO3enL/xr89vK2bh0Mfyj9aa0ANA== +"@emotion/styled-base@^10.0.27": + version "10.0.31" + resolved "https://registry.yarnpkg.com/@emotion/styled-base/-/styled-base-10.0.31.tgz#940957ee0aa15c6974adc7d494ff19765a2f742a" + integrity sha512-wTOE1NcXmqMWlyrtwdkqg87Mu6Rj1MaukEoEmEkHirO5IoHDJ8LgCQL4MjJODgxWxXibGR3opGp1p7YvkNEdXQ== + dependencies: + "@babel/runtime" "^7.5.5" + "@emotion/is-prop-valid" "0.8.8" + "@emotion/serialize" "^0.11.15" + "@emotion/utils" "0.11.3" + +"@emotion/styled@^10.0.27": + version "10.0.27" + resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-10.0.27.tgz#12cb67e91f7ad7431e1875b1d83a94b814133eaf" + integrity sha512-iK/8Sh7+NLJzyp9a5+vIQIXTYxfT4yB/OJbjzQanB2RZpvmzBQOHZWhpAMZWYEKRNNbsD6WfBw5sVWkb6WzS/Q== + dependencies: + "@emotion/styled-base" "^10.0.27" + babel-plugin-emotion "^10.0.27" + "@emotion/stylis@0.8.5": version "0.8.5" resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.8.5.tgz#deacb389bd6ee77d1e7fcaccce9e16c5c7e78e04" integrity sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ== +"@emotion/stylis@^0.7.0": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@emotion/stylis/-/stylis-0.7.1.tgz#50f63225e712d99e2b2b39c19c70fff023793ca5" + integrity sha512-/SLmSIkN13M//53TtNxgxo57mcJk/UJIDFRKwOiLIBEyBHEcipgR6hNMQ/59Sl4VjCJ0Z/3zeAZyvnSLPG/1HQ== + "@emotion/unitless@0.7.5": version "0.7.5" resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.7.5.tgz#77211291c1900a700b8a78cfafda3160d76949ed" integrity sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg== +"@emotion/unitless@^0.6.2", "@emotion/unitless@^0.6.7": + version "0.6.7" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.6.7.tgz#53e9f1892f725b194d5e6a1684a7b394df592397" + integrity sha512-Arj1hncvEVqQ2p7Ega08uHLr1JuRYBuO5cIvcA+WWEQ5+VmkOE3ZXzl04NbQxeQpWX78G7u6MqxKuNX3wvYZxg== + "@emotion/utils@0.11.3": version "0.11.3" resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.11.3.tgz#a759863867befa7e583400d322652a3f44820924" integrity sha512-0o4l6pZC+hI88+bzuaX/6BgOvQVhbt2PfmxauVaYOGgbsAw14wdKyvMCZXnsnsHys94iadcF+RG/wZyx6+ZZBw== +"@emotion/utils@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-0.8.2.tgz#576ff7fb1230185b619a75d258cbc98f0867a8dc" + integrity sha512-rLu3wcBWH4P5q1CGoSSH/i9hrXs7SlbRLkoq9IGuoPYNGQvDJ3pt/wmOM+XgYjIDRMVIdkUWt0RsfzF50JfnCw== + "@emotion/weak-memoize@0.2.5": version "0.2.5" resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" @@ -1193,6 +1308,11 @@ dependencies: "@babel/types" "^7.3.0" +"@types/classnames@^2.2.7": + version "2.2.10" + resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" + integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1236,7 +1356,7 @@ dependencies: jest-diff "^24.3.0" -"@types/json-schema@^7.0.3": +"@types/json-schema@*", "@types/json-schema@^7.0.3": version "7.0.4" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" @@ -1278,6 +1398,14 @@ dependencies: "@types/react" "*" +"@types/react-jsonschema-form@^1.6.4": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@types/react-jsonschema-form/-/react-jsonschema-form-1.7.1.tgz#3068a53a6b77d75ba223fd0f6b95fa07297d42fe" + integrity sha512-mUU3efUOfEupoDSlCgJTOHEXJ90ZClQLThmoobkV6e8wnkSDP9EiUYza5br/QaJF8EAQFS/l/STfb/WDiUVr1w== + dependencies: + "@types/json-schema" "*" + "@types/react" "*" + "@types/react-router-dom@^5.1.3": version "5.1.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.3.tgz#b5d28e7850bd274d944c0fbbe5d57e6b30d71196" @@ -1293,12 +1421,28 @@ "@types/history" "*" "@types/react" "*" +"@types/react-select@^2.0.15": + version "2.0.19" + resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-2.0.19.tgz#59a80ef81a4a5cb37f59970c53a4894d15065199" + integrity sha512-5GGBO3npQ0G/poQmEn+kI3Vn3DoJ9WjRXCeGcpwLxd5rYmjYPH235lbYPX5aclXE2RqEXyFxd96oh0wYwPXYpg== + dependencies: + "@types/react" "*" + "@types/react-dom" "*" + "@types/react-transition-group" "*" + "@types/react-syntax-highlighter@^10.1.0": version "10.2.1" resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-10.2.1.tgz#b0f75c22cbe7d12104581648348d91d3cd7f13fa" dependencies: "@types/react" "*" +"@types/react-transition-group@*": + version "4.2.4" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.2.4.tgz#c7416225987ccdb719262766c1483da8f826838d" + integrity sha512-8DMUaDqh0S70TjkqU0DxOu80tFUiiaS9rxkWip/nb7gtvAsbqOXm02UCmR8zdcjWujgeYPiPNTVpVpKzUDotwA== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.0": version "16.9.22" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.22.tgz#f0288c92d94e93c4b43e3f5633edf788b2c040ae" @@ -1306,6 +1450,14 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/reactstrap@^8.0.4": + version "8.4.2" + resolved "https://registry.yarnpkg.com/@types/reactstrap/-/reactstrap-8.4.2.tgz#e7066d0e67e2924dab0a52c6aedcf922f2be53b6" + integrity sha512-ag4hfFqBZaeoNSSTKjCtedvdcO68QqqlBrFd3obg94JSmhgNTmHz50BvNJkf9NjSzx1yGTW4l/OyP/khLPKqww== + dependencies: + "@types/react" "*" + popper.js "^1.14.1" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1372,6 +1524,48 @@ semver "^6.3.0" tsutils "^3.17.1" +"@umijs/hooks@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@umijs/hooks/-/hooks-1.8.0.tgz#e3026435570523eb22dc15fe64429b4a5d7f6c1b" + integrity sha512-k4bArEQgXxc5XRgixWlE+QNC7vheuMwxZIbjtmxOzXS6Fi8cIddUBsdWdx5poYMUo1XZXrntyHuiG6pVWXku+g== + dependencies: + "@umijs/use-request" "^1.3.0" + intersection-observer "^0.7.0" + lodash.isequal "^4.5.0" + resize-observer-polyfill "^1.5.1" + screenfull "^5.0.0" + +"@umijs/use-request@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@umijs/use-request/-/use-request-1.3.0.tgz#71f5576bb4a48b1af2421dc6321e975840a6eaa8" + integrity sha512-rkjxHBpFwQxBhugoQ3F1DfLPvHWapOaNOsMmv41kuLNTgpk0r3KNJcEcMVXHjKjkdoAmVWO5wGCC3PlLKfa+pA== + dependencies: + lodash.debounce "^4.0.8" + lodash.throttle "^4.1.1" + umi-request "^1.2.17" + +"@vseth/components@^1.5.0-alpha.23": + version "1.5.0-alpha.23" + resolved "https://registry.yarnpkg.com/@vseth/components/-/components-1.5.0-alpha.23.tgz#25b15855723289f8a2c26c2417f5c46b36c6a107" + integrity sha512-GDwY/Jb8Oe4tEkreBGqVN8+rjRAVDsFR20MojHdsy6fqVwpuPv/S6NXKzKHfLA4WlOAYix45nqwwLtb7PJ7R6A== + dependencies: + "@types/classnames" "^2.2.7" + "@types/react-jsonschema-form" "^1.6.4" + "@types/react-select" "^2.0.15" + "@types/reactstrap" "^8.0.4" + classnames "^2.2.6" + react-jsonschema-form "^1.8.0" + react-responsive "^6.1.1" + react-select "^2.4.2" + reactstrap "^8.1.1" + unstated-next "^1.1.0" + use-onclickoutside "^0.3.1" + +"@vseth/vseth-theme@^1.5.0-alpha.9": + version "1.5.0-alpha.9" + resolved "https://registry.yarnpkg.com/@vseth/vseth-theme/-/vseth-theme-1.5.0-alpha.9.tgz#d49a1647a03ec3c27f4f93a6a906dbef3a29a6a3" + integrity sha512-EnFFGoWWPX7+lEAL/zGluQJLxrEVRBup71aL1bfh0WtL1eN7uq66OAoeqFZKWBJrdt0UlE4KDQSUGXbcFETd+g== + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -1513,6 +1707,11 @@ abab@^2.0.0: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg== +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -1576,7 +1775,7 @@ ajv-keywords@^3.1.0, ajv-keywords@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.5.5, ajv@^6.7.0: version "6.12.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.0.tgz#06d60b96d87b8454a5adaba86e7854da629db4b7" dependencies: @@ -1652,6 +1851,11 @@ aproba@^1.1.1: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== +are-passive-events-supported@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/are-passive-events-supported/-/are-passive-events-supported-1.1.1.tgz#3db180a1753a2186a2de50a32cded3ac0979f5dc" + integrity sha512-5wnvlvB/dTbfrCvJ027Y4L4gW/6Mwoy1uFSavney0YO++GU+0e/flnjiBBwH+1kh7xNCgCOGvmJC3s32joYbww== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1726,7 +1930,7 @@ arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" -asap@~2.0.3, asap@~2.0.6: +asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -1887,6 +2091,24 @@ babel-plugin-emotion@^10.0.27: find-root "^1.1.0" source-map "^0.5.7" +babel-plugin-emotion@^9.2.11: + version "9.2.11" + resolved "https://registry.yarnpkg.com/babel-plugin-emotion/-/babel-plugin-emotion-9.2.11.tgz#319c005a9ee1d15bb447f59fe504c35fd5807728" + integrity sha512-dgCImifnOPPSeXod2znAmgc64NhaaOjGEHROR/M+lmStb3841yK1sgaDYAYMnlvWNz8GnpwIPN0VmNpbWYZ+VQ== + dependencies: + "@babel/helper-module-imports" "^7.0.0" + "@emotion/babel-utils" "^0.6.4" + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + babel-plugin-macros "^2.0.0" + babel-plugin-syntax-jsx "^6.18.0" + convert-source-map "^1.5.0" + find-root "^1.1.0" + mkdirp "^0.5.1" + source-map "^0.5.7" + touch "^2.0.1" + babel-plugin-istanbul@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-5.2.0.tgz#df4ade83d897a92df069c4d9a25cf2671293c854" @@ -2060,10 +2282,6 @@ boolbase@^1.0.0, boolbase@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" -bowser@^1.7.3: - version "1.9.4" - resolved "https://registry.yarnpkg.com/bowser/-/bowser-1.9.4.tgz#890c58a2813a9d3243704334fa81b96a5c150c9a" - brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2432,6 +2650,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" + integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== + clean-css@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" @@ -2647,7 +2870,7 @@ content-type@~1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" -convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.5.1, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" dependencies: @@ -2687,11 +2910,7 @@ core-js-compat@^3.6.2: browserslist "^4.8.3" semver "7.0.0" -core-js@^1.0.0: - version "1.2.7" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" - -core-js@^2.4.0: +core-js@^2.4.0, core-js@^2.5.7, core-js@^2.6.5: version "2.6.11" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" @@ -2739,6 +2958,19 @@ create-emotion@^10.0.27: "@emotion/sheet" "0.9.4" "@emotion/utils" "0.11.3" +create-emotion@^9.2.12: + version "9.2.12" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-9.2.12.tgz#0fc8e7f92c4f8bb924b0fef6781f66b1d07cb26f" + integrity sha512-P57uOF9NL2y98Xrbl2OuiDQUZ30GVmASsv5fbsjF4Hlraip2kyAvMm+2PoYUvFFw03Fhgtxk3RqZSm2/qHL9hA== + dependencies: + "@emotion/hash" "^0.6.2" + "@emotion/memoize" "^0.6.1" + "@emotion/stylis" "^0.7.0" + "@emotion/unitless" "^0.6.2" + csstype "^2.5.2" + stylis "^3.5.0" + stylis-rule-sheet "^0.0.10" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -2760,6 +2992,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: safe-buffer "^5.0.1" sha.js "^2.4.8" +create-react-context@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/create-react-context/-/create-react-context-0.3.0.tgz#546dede9dc422def0d3fc2fe03afe0bc0f4f7d8c" + integrity sha512-dNldIoSuNSvlTJ7slIKC/ZFGKexBMBrrcc+TTe1NdmROnaASuLPvqpwj9v4XS4uXZ8+YPu0sNmShX2rXI5LNsw== + dependencies: + gud "^1.0.0" + warning "^4.0.3" + cross-spawn@7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.1.tgz#0ab56286e0f7c24e153d04cc2aa027e43a9a5d14" @@ -2818,13 +3058,6 @@ css-has-pseudo@^0.10.0: postcss "^7.0.6" postcss-selector-parser "^5.0.0-rc.4" -css-in-js-utils@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz#3b472b398787291b47cfe3e44fecfdd9e914ba99" - dependencies: - hyphenate-style-name "^1.0.2" - isobject "^3.0.1" - css-loader@3.4.2: version "3.4.2" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-3.4.2.tgz#d3fdb3358b43f233b78501c5ed7b1c6da6133202" @@ -2842,6 +3075,11 @@ css-loader@3.4.2: postcss-value-parser "^4.0.2" schema-utils "^2.6.0" +css-mediaquery@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" + integrity sha1-aiw3NEkoYYYxxUvTPO3TAdoYvqA= + css-prefers-color-scheme@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-3.1.1.tgz#6f830a2714199d4f0d0d0bb8a27916ed65cff1f4" @@ -2988,10 +3226,15 @@ cssstyle@^1.0.0, cssstyle@^1.1.1: dependencies: cssom "0.3.x" -csstype@^2.2.0, csstype@^2.5.7: +csstype@^2.2.0, csstype@^2.5.2, csstype@^2.5.7: version "2.6.9" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" +csstype@^2.6.7: + version "2.6.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" + integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -3047,7 +3290,7 @@ decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" -deep-equal@^1.0.1: +deep-equal@^1.0.1, deep-equal@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" dependencies: @@ -3207,6 +3450,21 @@ dom-converter@^0.2: dependencies: utila "~0.4" +dom-helpers@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" + integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== + dependencies: + "@babel/runtime" "^7.1.2" + +dom-helpers@^5.0.1: + version "5.1.4" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" + integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^2.6.7" + dom-serializer@0, dom-serializer@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -3351,6 +3609,14 @@ emotion@^10.0.27: babel-plugin-emotion "^10.0.27" create-emotion "^10.0.27" +emotion@^9.1.2: + version "9.2.12" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-9.2.12.tgz#53925aaa005614e65c6e43db8243c843574d1ea9" + integrity sha512-hcx7jppaI8VoXxIWEhxpDW7I+B4kq9RNzQLmsrF6LY8BGKqe2N+gFAQr0EfuFucFlPs2A9HM4+xNj4NeqEWIOQ== + dependencies: + babel-plugin-emotion "^9.2.11" + create-emotion "^9.2.12" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -3850,18 +4116,6 @@ fb-watchman@^2.0.0: dependencies: bser "2.1.1" -fbjs@^0.8.12: - version "0.8.17" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" - dependencies: - core-js "^1.0.0" - isomorphic-fetch "^2.1.1" - loose-envify "^1.0.0" - object-assign "^4.1.0" - promise "^7.1.1" - setimmediate "^1.0.5" - ua-parser-js "^0.7.18" - figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -4163,16 +4417,6 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -glamor@^2.20.40: - version "2.20.40" - resolved "https://registry.yarnpkg.com/glamor/-/glamor-2.20.40.tgz#f606660357b7cf18dface731ad1a2cfa93817f05" - dependencies: - fbjs "^0.8.12" - inline-style-prefixer "^3.0.6" - object-assign "^4.1.1" - prop-types "^15.5.10" - through "^2.3.8" - glob-parent@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" @@ -4564,7 +4808,7 @@ https-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" -hyphenate-style-name@^1.0.2: +hyphenate-style-name@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.3.tgz#097bb7fa0b8f1a9cf0bd5c734cf95899981a9b48" @@ -4679,13 +4923,6 @@ ini@^1.3.5: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" -inline-style-prefixer@^3.0.6: - version "3.0.8" - resolved "https://registry.yarnpkg.com/inline-style-prefixer/-/inline-style-prefixer-3.0.8.tgz#8551b8e5b4d573244e66a34b04f7d32076a2b534" - dependencies: - bowser "^1.7.3" - css-in-js-utils "^2.0.0" - inquirer@7.0.4, inquirer@^7.0.0: version "7.0.4" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.4.tgz#99af5bde47153abca23f5c7fc30db247f39da703" @@ -4711,6 +4948,11 @@ internal-ip@^4.3.0: default-gateway "^4.2.0" ipaddr.js "^1.9.0" +intersection-observer@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.7.0.tgz#ee16bee978db53516ead2f0a8154b09b400bbdc9" + integrity sha512-Id0Fij0HsB/vKWGeBe9PxeY45ttRiBmhFyyt/geBdDHBYNctMRTE3dC1U3ujzz3lap+hVXlEcVaB56kZP/eEUg== + invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -5037,7 +5279,7 @@ isobject@^3.0.0, isobject@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" -isomorphic-fetch@^2.1.1: +isomorphic-fetch@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" dependencies: @@ -5760,6 +6002,16 @@ lodash.camelcase@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -5781,6 +6033,11 @@ lodash.templatesettings@^4.0.0: dependencies: lodash._reinterpolate "^3.0.0" +lodash.throttle@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" + integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -5861,6 +6118,13 @@ markdown-escapes@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" +matchmediaquery@^0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.3.1.tgz#8247edc47e499ebb7c58f62a9ff9ccf5b815c6d7" + integrity sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ== + dependencies: + css-mediaquery "^0.1.2" + md5.js@^1.3.4: version "1.3.5" resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" @@ -5891,6 +6155,11 @@ mem@^4.0.0: mimic-fn "^2.0.0" p-is-promise "^2.0.0" +memoize-one@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" + integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== + memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" @@ -6128,6 +6397,11 @@ nan@^2.12.1: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" +nanoid@^2.1.0: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -6239,6 +6513,13 @@ node-releases@^1.1.47, node-releases@^1.1.49: dependencies: semver "^6.3.0" +nopt@~1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" + integrity sha1-bd0hvSoxQXuScn3Vhfim83YI6+4= + dependencies: + abbrev "1" + normalize-package-data@^2.3.2: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -6801,6 +7082,11 @@ pnp-webpack-plugin@1.6.0: dependencies: ts-pnp "^1.1.2" +popper.js@^1.14.1, popper.js@^1.14.4: + version "1.16.1" + resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" + integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== + portfinder@^1.0.25: version "1.0.25" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca" @@ -7463,12 +7749,6 @@ promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - dependencies: - asap "~2.0.3" - promise@^8.0.3: version "8.0.3" resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.3.tgz#f592e099c6cddc000d538ee7283bb190452b0bf6" @@ -7482,7 +7762,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.4" -prop-types@^15.5.10, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" dependencies: @@ -7564,6 +7844,11 @@ qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" +qs@^6.9.1: + version "6.9.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.1.tgz#20082c65cb78223635ab1a9eaca8875a29bf8ec9" + integrity sha512-Cxm7/SS/y/Z3MHWSxXb8lIFqgqBowP5JMlTUFyJN88y0SGQhVmZnqFK/PeuMX9LzUyWsqqhNxIyg0jlzq946yA== + qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -7587,7 +7872,7 @@ querystringify@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.1.1.tgz#60e5a5fd64a7f8bfa4d2ab2ed6fdf4c85bad154e" -raf@^3.4.1: +raf@^3.4.0, raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" dependencies: @@ -7682,10 +7967,36 @@ react-feather@^2.0.3: dependencies: prop-types "^15.7.2" +react-input-autosize@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2" + integrity sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw== + dependencies: + prop-types "^15.5.8" + react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" +react-jsonschema-form@^1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/react-jsonschema-form/-/react-jsonschema-form-1.8.1.tgz#9c962f29a55b3fe071d8edf2fc3430f05f1b7ed9" + integrity sha512-aaDloxNAcGXOOOcdKOxxqEEn5oDlPUZgWcs8unXXB9vjBRgCF8rCm/wVSv1u2G5ih0j/BX6Ewd/WjI2g00lPdg== + dependencies: + "@babel/runtime-corejs2" "^7.4.5" + ajv "^6.7.0" + core-js "^2.5.7" + lodash "^4.17.15" + prop-types "^15.5.8" + react-is "^16.8.4" + react-lifecycles-compat "^3.0.4" + shortid "^2.2.14" + +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-markdown@^4.0.3: version "4.3.1" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-4.3.1.tgz#39f0633b94a027445b86c9811142d05381300f2f" @@ -7699,6 +8010,28 @@ react-markdown@^4.0.3: unist-util-visit "^1.3.0" xtend "^4.0.1" +react-popper@^1.3.6: + version "1.3.7" + resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-1.3.7.tgz#f6a3471362ef1f0d10a4963673789de1baca2324" + integrity sha512-nmqYTx7QVjCm3WUZLeuOomna138R1luC4EqkW3hxJUrAe+3eNz3oFCLYdnPwILfn0mX1Ew2c3wctrjlUMYYUww== + dependencies: + "@babel/runtime" "^7.1.2" + create-react-context "^0.3.0" + deep-equal "^1.1.1" + popper.js "^1.14.4" + prop-types "^15.6.1" + typed-styles "^0.0.7" + warning "^4.0.2" + +react-responsive@^6.1.1: + version "6.1.2" + resolved "https://registry.yarnpkg.com/react-responsive/-/react-responsive-6.1.2.tgz#b9b9cc3ee35f37e80cb036f1c9038ef73aec920b" + integrity sha512-AXentVC/kN3KED9zhzJv2pu4vZ0i6cSHdTtbCScVV1MT6F5KXaG2qs5D7WLmhdaOvmiMX8UfmS4ZSO+WPwDt4g== + dependencies: + hyphenate-style-name "^1.0.0" + matchmediaquery "^0.3.0" + prop-types "^15.6.1" + react-router-dom@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" @@ -7785,6 +8118,19 @@ react-scripts@3.4.0: optionalDependencies: fsevents "2.1.2" +react-select@^2.4.2: + version "2.4.4" + resolved "https://registry.yarnpkg.com/react-select/-/react-select-2.4.4.tgz#ba72468ef1060c7d46fbb862b0748f96491f1f73" + integrity sha512-C4QPLgy9h42J/KkdrpVxNmkY6p4lb49fsrbDk/hRcZpX7JvZPNb6mGj+c5SzyEtBv1DmQ9oPH4NmhAFvCrg8Jw== + dependencies: + classnames "^2.2.5" + emotion "^9.1.2" + memoize-one "^5.0.0" + prop-types "^15.6.0" + raf "^3.4.0" + react-input-autosize "^2.2.1" + react-transition-group "^2.2.1" + react-syntax-highlighter@^10.2.1: version "10.3.5" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-10.3.5.tgz#3b3e2d1eba92fb7988c3b50d22d2c74ae0263fdd" @@ -7795,6 +8141,26 @@ react-syntax-highlighter@^10.2.1: prismjs "^1.8.4" refractor "^2.4.1" +react-transition-group@^2.2.1, react-transition-group@^2.3.1: + version "2.9.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" + integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== + dependencies: + dom-helpers "^3.4.0" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-lifecycles-compat "^3.0.4" + +react-transition-group@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.3.0.tgz#fea832e386cf8796c58b61874a3319704f5ce683" + integrity sha512-1qRV1ZuVSdxPlPf4O8t7inxUGpdyO5zG9IoNfJxSO0ImU2A1YWkEQvFPuIPZmMLkg5hYs7vv5mMOyfgSkvAwvw== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@^16.12.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83" @@ -7803,6 +8169,18 @@ react@^16.12.0: object-assign "^4.1.1" prop-types "^15.6.2" +reactstrap@^8.1.1: + version "8.4.1" + resolved "https://registry.yarnpkg.com/reactstrap/-/reactstrap-8.4.1.tgz#c7f63b9057f58b52833061711ebe235b9ec4e3e5" + integrity sha512-oAjp9PYYUGKl7SLXwrQ1oRIrYw0MqfO2mUqYgGapFKHG2uwjEtLip5rYxtMujkGx3COjH5FX1WtcfNU4oqpH0Q== + dependencies: + "@babel/runtime" "^7.2.0" + classnames "^2.2.3" + prop-types "^15.5.8" + react-lifecycles-compat "^3.0.4" + react-popper "^1.3.6" + react-transition-group "^2.3.1" + read-pkg-up@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" @@ -7912,6 +8290,11 @@ regenerator-runtime@^0.13.2, regenerator-runtime@^0.13.3: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" +regenerator-runtime@^0.13.4: + version "0.13.5" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" + integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== + regenerator-transform@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb" @@ -8076,6 +8459,11 @@ requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -8289,6 +8677,11 @@ schema-utils@^2.5.0, schema-utils@^2.6.0, schema-utils@^2.6.1, schema-utils@^2.6 ajv "^6.10.2" ajv-keywords "^3.4.1" +screenfull@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.0.2.tgz#b9acdcf1ec676a948674df5cd0ff66b902b0bed7" + integrity sha512-cCF2b+L/mnEiORLN5xSAz6H3t18i2oHh9BA8+CQlAh5DRw2+NFAGQJOSYbcGw8B2k04g/lVvFcfZ83b3ysH5UQ== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -8371,7 +8764,7 @@ set-value@^2.0.0, set-value@^2.0.1: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -8433,6 +8826,13 @@ shellwords@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" +shortid@^2.2.14: + version "2.2.15" + resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.15.tgz#2b902eaa93a69b11120373cd42a1f1fe4437c122" + integrity sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw== + dependencies: + nanoid "^2.1.0" + signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" @@ -8551,6 +8951,11 @@ source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" +source-map@^0.7.2: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + space-separated-tokens@^1.0.0: version "1.1.5" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" @@ -8838,6 +9243,16 @@ stylehacks@^4.0.0: postcss "^7.0.0" postcss-selector-parser "^3.0.0" +stylis-rule-sheet@^0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== + +stylis@^3.5.0: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + supports-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7" @@ -8960,7 +9375,7 @@ through2@^2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -through@^2.3.6, through@^2.3.8: +through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" @@ -9040,6 +9455,13 @@ toidentifier@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" +touch@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/touch/-/touch-2.0.2.tgz#ca0b2a3ae3211246a61b16ba9e6cbf1596287164" + integrity sha512-qjNtvsFXTRq7IuMLweVgFxmEuQ6gLbRs2jQxL80TtZ31dEKWYIxRXquij6w6VimyDek5hD3PytljHmEtAs2u0A== + dependencies: + nopt "~1.0.10" + tough-cookie@^2.3.3, tough-cookie@^2.3.4, tough-cookie@^2.5.0, tough-cookie@~2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" @@ -9122,6 +9544,11 @@ type@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/type/-/type-2.0.0.tgz#5f16ff6ef2eb44f260494dae271033b29c09a9c3" +typed-styles@^0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" + integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== + typedarray@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" @@ -9130,9 +9557,13 @@ typescript@~3.7.2: version "3.7.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae" -ua-parser-js@^0.7.18: - version "0.7.21" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777" +umi-request@^1.2.17: + version "1.2.19" + resolved "https://registry.yarnpkg.com/umi-request/-/umi-request-1.2.19.tgz#57ec16322506674f2d6392d553119829cedb1589" + integrity sha512-gN3OyEJGs+h4Ly2UFBL/5vhFmTM11VJ3OIq2AB3tq3PCVwqwpjY09MAwzCT4rr8Ti02kdxcPaAEUllLP5XvJRQ== + dependencies: + isomorphic-fetch "^2.2.1" + qs "^6.9.1" unherit@^1.0.4: version "1.1.3" @@ -9249,6 +9680,11 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" +unstated-next@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unstated-next/-/unstated-next-1.1.0.tgz#7bb4911a12fdf3cc8ad3eb11a0b315e4a8685ea8" + integrity sha512-AAn47ZncPvgBGOvMcn8tSRxsrqwf2VdAPxLASTuLJvZt4rhKfDvUkmYZLGfclImSfTVMv7tF4ynaVxin0JjDCA== + upath@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" @@ -9285,6 +9721,19 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-latest@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.0.0.tgz#c86d2e4893b15f27def69da574a47136d107facb" + integrity sha512-CxmFi75KTXeTIBlZq3LhJ4Hz98pCaRKZHCpnbiaEHIr5QnuHvH8lKYoluPBt/ik7j/hFVPB8K3WqF6mQvLyQTg== + +use-onclickoutside@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/use-onclickoutside/-/use-onclickoutside-0.3.1.tgz#fdd723a6a499046b6bc761e4a03af432eee5917b" + integrity sha512-aahvbW5+G0XJfzj31FJeLsvc6qdKbzeTsQ8EtkHHq5qTg6bm/qkJeKLcgrpnYeHDDbd7uyhImLGdkbM9BRzOHQ== + dependencies: + are-passive-events-supported "^1.1.0" + use-latest "^1.0.0" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" @@ -9411,6 +9860,13 @@ walker@^1.0.7, walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^4.0.2, warning@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" + integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + dependencies: + loose-envify "^1.0.0" + watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"