diff --git a/frontend/.env.example b/frontend/.env.example index 40e5294d..5705a99f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,2 @@ -VITE_BACKEND_URL=http://localhost:3000/ \ No newline at end of file +VITE_BACKEND_URL=http://localhost:3000/ +VITE_AUTHOR_NAME_MAX_LENGTH=12 diff --git a/frontend/src/components/avatar/Avatar.scss b/frontend/src/components/avatar/Avatar.scss new file mode 100644 index 00000000..c87b1b74 --- /dev/null +++ b/frontend/src/components/avatar/Avatar.scss @@ -0,0 +1,8 @@ +.avatar { + border-radius: 50%; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); + height: 40px !important; + object-fit: cover; + pointer-events: none; + width: 40px !important; +} diff --git a/frontend/src/components/avatar/Avatar.tsx b/frontend/src/components/avatar/Avatar.tsx new file mode 100644 index 00000000..96647770 --- /dev/null +++ b/frontend/src/components/avatar/Avatar.tsx @@ -0,0 +1,12 @@ +import "./Avatar.scss"; +import React from "react"; + +export default function Avatar(props: { userId?: string }) { + return { { + currentTarget.onerror = null; // prevents looping + currentTarget.src = "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50"; + } }/> +} diff --git a/frontend/src/components/button/Button.scss b/frontend/src/components/button/Button.scss index e13ffa54..6de3423f 100644 --- a/frontend/src/components/button/Button.scss +++ b/frontend/src/components/button/Button.scss @@ -23,10 +23,6 @@ overflow: hidden; position: relative; - * { - z-index: 10; - } - &::before { animation: rotate 2s infinite linear; background-color: transparent; diff --git a/frontend/src/components/button/Button.tsx b/frontend/src/components/button/Button.tsx index 11121e79..7e2257dd 100644 --- a/frontend/src/components/button/Button.tsx +++ b/frontend/src/components/button/Button.tsx @@ -1,5 +1,6 @@ import React from "react"; import "./Button.scss"; +import { animateBlob } from "../../def/cool-blobs"; interface Props { buttonStyle?: "primary" | "glass", @@ -7,7 +8,8 @@ interface Props { onClick?: () => Promise, children?: React.ReactNode, disabled?: boolean, - placeIconRight?: boolean + placeIconRight?: boolean, + type?: "button" | "reset" | "submit" } /** @@ -24,8 +26,11 @@ export default function Button(props: Props) { return ( ); } diff --git a/frontend/src/components/dropdown/Dropdown.scss b/frontend/src/components/dropdown/Dropdown.scss index d0ae8a2e..8dd26581 100644 --- a/frontend/src/components/dropdown/Dropdown.scss +++ b/frontend/src/components/dropdown/Dropdown.scss @@ -98,6 +98,7 @@ border: none; cursor: pointer; display: flex; + gap: var(--spacing); padding: var(--spacing); width: 100%; @@ -113,18 +114,12 @@ } > i:first-child { - margin-inline-end: var(--spacing); text-align: center; width: 20px; } - > i:last-child { - margin-inline-start: var(--spacing); - } - > span:first-of-type { flex: 1; - padding-inline-end: calc(var(--spacing) * 2); } .shortcut { diff --git a/frontend/src/components/dropdown/Dropdown.tsx b/frontend/src/components/dropdown/Dropdown.tsx index 97c93fcb..88b9799f 100644 --- a/frontend/src/components/dropdown/Dropdown.tsx +++ b/frontend/src/components/dropdown/Dropdown.tsx @@ -1,5 +1,6 @@ import React from "react"; import "./Dropdown.scss"; +import { animateBlob } from "../../def/cool-blobs"; interface Item { label: string, @@ -60,7 +61,9 @@ export default function Dropdown(props: { { item.header &&
{ item.header }
} { item.expanded && item.items && item.items.length > 0 && renderItems(item.items, level + 1, diff --git a/frontend/src/components/questionpreview/QuestionAnswer.tsx b/frontend/src/components/questionpreview/QuestionAnswer.tsx new file mode 100644 index 00000000..7a1b0407 --- /dev/null +++ b/frontend/src/components/questionpreview/QuestionAnswer.tsx @@ -0,0 +1,68 @@ +import { Answer } from "../../def/Question"; +import React from "react"; +import Avatar from "../avatar/Avatar"; +import { useTranslation } from "react-i18next"; + +/** + * Renders an answer to be displayed in QuestionView + * @param props holds the answer and an index + */ +export default function QuestionAnswer(props: { answer: Answer, index: number }) { + const { t } = useTranslation(); + + return
+
+ { props.answer.author.type === "ai" + ? + : } + +

+ { props.answer.author.name } + { props.answer.author.type.toUpperCase() } +

+ + + { t('dashboard.questionView.answer.creationDate', { answerCreationDate: props.answer.created }) } + +
+ +
+
+

{ props.answer.content }

+
+ +
+
+ + { props.answer.likes } + { t('dashboard.questionView.answer.likes') } +
+ +
+ + { props.answer.dislikes } + { t('dashboard.questionView.answer.dislikes') } +
+ +
+ + +
+
+
+} diff --git a/frontend/src/components/questionpreview/QuestionPreview.tsx b/frontend/src/components/questionpreview/QuestionPreview.tsx index 86331294..0ef13421 100644 --- a/frontend/src/components/questionpreview/QuestionPreview.tsx +++ b/frontend/src/components/questionpreview/QuestionPreview.tsx @@ -2,6 +2,7 @@ import "./QuestionPreview.scss"; import React from "react"; import { useNavigate } from "react-router-dom"; import { Question } from "../../def/Question"; +import Avatar from "../avatar/Avatar"; interface Props { question: Question; @@ -63,7 +64,7 @@ export default function QuestionPreview(props: Props) {
+ className={ "question-stat" + (props.question.opinion === "like" ? " rating" : "") }> { props.question.likes.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") } @@ -71,7 +72,7 @@ export default function QuestionPreview(props: Props) {
+ className={ "question-stat" + (props.question.opinion === "dislike" ? " rating" : "") }> { props.question.dislikes.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",") } @@ -96,10 +97,10 @@ export default function QuestionPreview(props: Props) {
- { +

Asked by - { props.question.author.name } + { props.question.author.name.substring(0, import.meta.env.VITE_AUTHOR_NAME_MAX_LENGTH) }

diff --git a/frontend/src/components/questionpreview/QuestionPreviewSkeleton.tsx b/frontend/src/components/questionpreview/QuestionPreviewSkeleton.tsx index 23a6fb81..bd1a6001 100644 --- a/frontend/src/components/questionpreview/QuestionPreviewSkeleton.tsx +++ b/frontend/src/components/questionpreview/QuestionPreviewSkeleton.tsx @@ -5,47 +5,52 @@ import React from "react"; /** * Renders a skeleton, based on QuestionPreview */ -export default function QuestionPreviewSkeleton() { - return
-
- - -

- -

- - -
- -
-
-
- -
+export default function QuestionPreviewSkeleton(props: { count?: number }) { + const { count = 1 } = props; + const skeletonArray = Array.from({ length: count }); + + return skeletonArray.map((_, i) => { + return
+
+ -
- -
+

+ +

+ +
-
-
- +
+
+
+ +
+ +
+ +
-
- +
+
+ +
+ +
+ +
-
- -
- -

- - -

+
+ + +

+ + +

+
-
+ }) } diff --git a/frontend/src/def/Question.d.ts b/frontend/src/def/Question.d.ts index a59395c8..136bfd63 100644 --- a/frontend/src/def/Question.d.ts +++ b/frontend/src/def/Question.d.ts @@ -6,7 +6,7 @@ export interface Question { tags: string[]; likes: number; dislikes: number; - rating: "like" | "dislike" | "none"; + opinion: "like" | "dislike" | "none"; answers: number; created: string; updated: string; @@ -19,12 +19,12 @@ export interface Answer { created: string; likes: number; dislikes: number; - rating: "like" | "dislike" | "none"; + opinion: "like" | "dislike" | "none"; author: Author; } interface Author { id: string; name: string; - type: "user" | "pro" | "ai"; + type: "guest" | "user" | "pro" | "admin" | "ai"; } diff --git a/frontend/src/def/cool-blobs.ts b/frontend/src/def/cool-blobs.ts new file mode 100644 index 00000000..4d023e96 --- /dev/null +++ b/frontend/src/def/cool-blobs.ts @@ -0,0 +1,30 @@ +import React from "react"; + +export const animateBlob = (e: React.MouseEvent) => { + const _blob = e.currentTarget.querySelector("span.button-blob"); + if (!_blob) return; + let blob = _blob as HTMLElement; + + let buttonRect = e.currentTarget.getBoundingClientRect(); + let docEl = document.documentElement; + let left = buttonRect.left + (window.pageXOffset || docEl.scrollLeft || 0); + + let x = e.clientX - left; + + const fromLeftLimit = 30; + const fromRightLimit = (buttonRect.width - fromLeftLimit); + let fromX = x < fromLeftLimit ? fromLeftLimit : x > fromRightLimit ? fromRightLimit : x; + + const toLeftLimit = 60; + const toRightLimit = (buttonRect.width - toLeftLimit); + let toX = x < toLeftLimit ? toLeftLimit : x > toRightLimit ? toRightLimit : x; + + if (blob.animate) blob.animate([ + { opacity: 1, width: "60px", height: "80%", top: "10%", transform: "translateX(calc(" + fromX + "px - 50%))" }, + { opacity: 0, width: "120px", height: "100%", top: 0, transform: "translateX(calc(" + toX + "px - 50%))" } + ], { + duration: 400, + fill: "forwards", + easing: "ease" + }); +} diff --git a/frontend/src/index.scss b/frontend/src/index.scss index 8f153808..c0cb036c 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -95,15 +95,6 @@ code { } } -.avatar { - border-radius: 50%; - filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5)); - height: 40px !important; - object-fit: cover; - pointer-events: none; - width: 40px !important; -} - ::selection { background-color: var(--primary-color); color: var(--primary-color-contrast); @@ -259,3 +250,23 @@ hr { color: var(--primary-color-contrast); } } + +*:has(> span.button-blob) { + position: relative; + + * { + z-index: 10; + } + + span.button-blob { + position: absolute; + height: 100%; + width: 60px; + top: 0; + left: 0; + border-radius: var(--border-radius); + background: var(--border-color); + z-index: 1; + opacity: 0; + } +} diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index dfd8db2d..1a0eb7c9 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -90,6 +90,14 @@ } }, "submit": "Post your Question" + }, + "questionView": { + "answer": { + "creationDate": "replied {{answerCreationDate}}", + "likes": "likes", + "dislikes": "dislikes", + "report": "Report answer" + } } } } diff --git a/frontend/src/pages/dashboard/Dashboard.scss b/frontend/src/pages/dashboard/Dashboard.scss index defc6a4e..e6550a17 100644 --- a/frontend/src/pages/dashboard/Dashboard.scss +++ b/frontend/src/pages/dashboard/Dashboard.scss @@ -30,7 +30,7 @@ justify-content: center; } - a { + a.navigate { align-items: center; border-radius: var(--border-radius) 0 0 var(--border-radius); color: inherit; diff --git a/frontend/src/pages/dashboard/Dashboard.tsx b/frontend/src/pages/dashboard/Dashboard.tsx index 5900e40b..28b3c513 100644 --- a/frontend/src/pages/dashboard/Dashboard.tsx +++ b/frontend/src/pages/dashboard/Dashboard.tsx @@ -14,6 +14,8 @@ import Skeleton from "react-loading-skeleton"; import Search from "../../components/search/Search"; import { axiosError } from "../../def/axios-error"; import { useAlert } from "react-alert"; +import Avatar from "../../components/avatar/Avatar"; +import { animateBlob } from "../../def/cool-blobs"; // ory setup const basePath = "http://localhost:4000" @@ -56,7 +58,7 @@ export default function Dashboard(props: Props) { if (window.location.pathname.includes("/question")) { let questionId = window.location.pathname.split("/question/")[1].substring(0, 36); global.axios.get("question/" + questionId + "/title") - .then(res => console.log(res)) + .then(res => setActiveQuestionName(res.data.title)) .catch(err => axiosError(err, alert)); } } @@ -168,13 +170,7 @@ export default function Dashboard(props: Props) { boxShadow: "var(--box-shadow)" } } tabIndex={ 0 }> - { { - currentTarget.onerror = null; // prevents looping - currentTarget.src = "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50"; - } }/> +
} items={ [ { @@ -261,14 +257,21 @@ export default function Dashboard(props: Props) { return