From 1f9f031a9fa10a1a6eafcc6e148e9e346819eadd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mikko=20Forsstr=C3=B6m?=
Date: Sat, 11 Nov 2023 18:21:31 +0200
Subject: [PATCH 01/17] Hip
---
src/app/(app)/a/[id]/page.tsx | 46 +++++++++++++
src/app/(app)/error.tsx | 22 +++++++
src/app/(app)/layout.tsx | 33 ++++++++++
src/app/(app)/page.tsx | 45 +++++++++++++
src/app/(welcome)/layout.tsx | 2 +
src/app/(welcome)/page.tsx | 5 --
src/components/Clock.tsx | 25 +++++++
src/components/Clocks.css.ts | 22 +++++++
src/components/Clocks.tsx | 56 ++++++++++++++++
src/components/Currencies.css.ts | 6 ++
src/components/Currencies.tsx | 64 ++++++++++++++++++
src/components/CurrenciesList.tsx | 65 +++++++++++++++++++
src/components/Footer.css.ts | 12 ++++
src/components/Footer.tsx | 12 ++++
src/components/Header.css.ts | 20 ++++++
src/components/Header.tsx | 15 +++++
src/components/Headline.css.ts | 11 ++++
src/components/Headline.tsx | 30 +++++++++
src/components/Headlines.tsx | 19 ++++++
src/components/LatestQuarticles.tsx | 34 ++++++++++
src/components/Layout.css.ts | 16 +++++
src/components/Main.css.ts | 7 ++
src/components/Main.tsx | 12 ++++
src/components/Provider.tsx | 22 +++++++
src/components/comments/Comment.css.ts | 6 ++
src/components/comments/Comment.tsx | 20 ++++++
src/components/comments/Comments.css.ts | 6 ++
src/components/comments/Comments.tsx | 55 ++++++++++++++++
src/components/comments/CommentsForm.tsx | 57 ++++++++++++++++
src/components/duck-ui/Button.css.ts | 6 ++
src/components/duck-ui/Button.tsx | 19 ++++++
src/components/duck-ui/Input.css.ts | 7 ++
src/components/duck-ui/Input.tsx | 19 ++++++
src/components/pages/IndexPageView.css.ts | 49 ++++++++++++++
src/components/pages/IndexPageView.tsx | 44 +++++++++++++
src/components/quarticle/HeadingBlock.tsx | 12 ++++
src/components/quarticle/ImageBlock.css.ts | 9 +++
src/components/quarticle/ImageBlock.tsx | 13 ++++
src/components/quarticle/MarkdownBlock.tsx | 13 ++++
.../quarticle/QuarticleRenderer.css.ts | 5 ++
.../quarticle/QuarticleRenderer.tsx | 39 +++++++++++
src/components/quarticle/UnknownBlock.tsx | 12 ++++
src/components/theme.css.ts | 43 ++++++++++++
src/components/welcome/BackendChecker.tsx | 5 ++
src/components/welcome/HotReloadTester.tsx | 7 +-
.../welcome/VanillaExtractChecker.css.ts | 3 +-
src/components/welcome/Welcome.tsx | 8 ++-
src/hooks/useTime.tsx | 20 ++++++
48 files changed, 1064 insertions(+), 14 deletions(-)
create mode 100644 src/app/(app)/a/[id]/page.tsx
create mode 100644 src/app/(app)/error.tsx
create mode 100644 src/app/(app)/layout.tsx
create mode 100644 src/app/(app)/page.tsx
delete mode 100644 src/app/(welcome)/page.tsx
create mode 100644 src/components/Clock.tsx
create mode 100644 src/components/Clocks.css.ts
create mode 100644 src/components/Clocks.tsx
create mode 100644 src/components/Currencies.css.ts
create mode 100644 src/components/Currencies.tsx
create mode 100644 src/components/CurrenciesList.tsx
create mode 100644 src/components/Footer.css.ts
create mode 100644 src/components/Footer.tsx
create mode 100644 src/components/Header.css.ts
create mode 100644 src/components/Header.tsx
create mode 100644 src/components/Headline.css.ts
create mode 100644 src/components/Headline.tsx
create mode 100644 src/components/Headlines.tsx
create mode 100644 src/components/LatestQuarticles.tsx
create mode 100644 src/components/Layout.css.ts
create mode 100644 src/components/Main.css.ts
create mode 100644 src/components/Main.tsx
create mode 100644 src/components/Provider.tsx
create mode 100644 src/components/comments/Comment.css.ts
create mode 100644 src/components/comments/Comment.tsx
create mode 100644 src/components/comments/Comments.css.ts
create mode 100644 src/components/comments/Comments.tsx
create mode 100644 src/components/comments/CommentsForm.tsx
create mode 100644 src/components/duck-ui/Button.css.ts
create mode 100644 src/components/duck-ui/Button.tsx
create mode 100644 src/components/duck-ui/Input.css.ts
create mode 100644 src/components/duck-ui/Input.tsx
create mode 100644 src/components/pages/IndexPageView.css.ts
create mode 100644 src/components/pages/IndexPageView.tsx
create mode 100644 src/components/quarticle/HeadingBlock.tsx
create mode 100644 src/components/quarticle/ImageBlock.css.ts
create mode 100644 src/components/quarticle/ImageBlock.tsx
create mode 100644 src/components/quarticle/MarkdownBlock.tsx
create mode 100644 src/components/quarticle/QuarticleRenderer.css.ts
create mode 100644 src/components/quarticle/QuarticleRenderer.tsx
create mode 100644 src/components/quarticle/UnknownBlock.tsx
create mode 100644 src/components/theme.css.ts
create mode 100644 src/hooks/useTime.tsx
diff --git a/src/app/(app)/a/[id]/page.tsx b/src/app/(app)/a/[id]/page.tsx
new file mode 100644
index 00000000..71f3e27f
--- /dev/null
+++ b/src/app/(app)/a/[id]/page.tsx
@@ -0,0 +1,46 @@
+import { cache } from "react";
+
+import Comments from "@/components/comments/Comments";
+import QuarticleRenderer from "@/components/quarticle/QuarticleRenderer";
+import * as quarticleService from "@/services/quarticle";
+import { Metadata } from "next";
+import { notFound } from "next/navigation";
+
+type Props = {
+ params: {
+ id: string;
+ };
+};
+
+const getQuarticle = cache(quarticleService.getQuarticle);
+
+export async function generateMetadata({ params }: Props): Promise {
+ try {
+ const cert = await getQuarticle(params.id);
+ return {
+ title: `${cert.headline} - Kvauppalehti`
+ };
+ } catch (e) {
+ notFound();
+ }
+}
+
+const QuarticlePage = async ({ params }: Props) => {
+ try {
+ const quarticle = await getQuarticle(params.id);
+
+ return (
+
+
+
+
+
+
+
+ );
+ } catch (e) {
+ notFound();
+ }
+};
+
+export default QuarticlePage;
diff --git a/src/app/(app)/error.tsx b/src/app/(app)/error.tsx
new file mode 100644
index 00000000..1fac5b6b
--- /dev/null
+++ b/src/app/(app)/error.tsx
@@ -0,0 +1,22 @@
+"use client"; // Error components must be Client Components
+
+import { useEffect } from "react";
+
+export default function Error({
+ error,
+ reset
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("ERRORE FATALE", error);
+ }, [error]);
+
+ return (
+
+
Carricamento! Errore fatale!
+
+
+ );
+}
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
new file mode 100644
index 00000000..d50a3d5a
--- /dev/null
+++ b/src/app/(app)/layout.tsx
@@ -0,0 +1,33 @@
+import { ReactNode } from "react";
+import Header from "@/components/Header";
+import Footer from "@/components/Footer";
+
+import "normalize.css";
+import Main from "@/components/Main";
+
+import "@/components/Layout.css";
+import Provider from "@/components/Provider";
+
+type Props = {
+ children: ReactNode;
+};
+
+export default async function RootLayout({ children }: Props) {
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx
new file mode 100644
index 00000000..b7081544
--- /dev/null
+++ b/src/app/(app)/page.tsx
@@ -0,0 +1,45 @@
+import IndexPageView from "@/components/pages/IndexPageView";
+import * as currencyService from "@/services/currency";
+import * as quarticleService from "@/services/quarticle";
+import { Metadata } from "next";
+
+import { DateTime } from "luxon";
+import { YEAR_MONTH_DAY } from "@/services/date";
+import { cache } from "react";
+
+export const metadata: Metadata = {
+ title: "Pääsivu - Kvauppalehti"
+};
+
+const fetchCurrencyRates = cache(
+ async (date: string): Promise => {
+ try {
+ const rates = await currencyService.getCurrencyRates(date);
+ return rates;
+ } catch (e) {
+ return {
+ date: date,
+ rates: {}
+ };
+ }
+ }
+);
+
+export default async function IndexPage() {
+ const now = DateTime.utc();
+
+ const serverTime = now.toISO() as string;
+ const date = now.toFormat(YEAR_MONTH_DAY);
+
+ const rates = await fetchCurrencyRates(date);
+
+ const latestQuarticles = await quarticleService.getQuarticles(0, 10);
+
+ return (
+
+ );
+}
diff --git a/src/app/(welcome)/layout.tsx b/src/app/(welcome)/layout.tsx
index 298e6f15..c6f39112 100644
--- a/src/app/(welcome)/layout.tsx
+++ b/src/app/(welcome)/layout.tsx
@@ -2,6 +2,8 @@ import { ReactNode } from "react";
import "normalize.css";
+import "@/components/Layout.css";
+
type Props = {
children: ReactNode;
};
diff --git a/src/app/(welcome)/page.tsx b/src/app/(welcome)/page.tsx
deleted file mode 100644
index 300a9303..00000000
--- a/src/app/(welcome)/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from "next/navigation";
-
-export default async function IndexPage() {
- redirect("/welcome");
-}
diff --git a/src/components/Clock.tsx b/src/components/Clock.tsx
new file mode 100644
index 00000000..71b4322e
--- /dev/null
+++ b/src/components/Clock.tsx
@@ -0,0 +1,25 @@
+import { DateTime } from "luxon";
+import { FC } from "react";
+
+type Props = {
+ time: DateTime;
+ name: string;
+ locale: string;
+ zone: string;
+};
+
+const Clock: FC = ({ time, locale, zone, name }) => {
+ return (
+
+
{name}
+
+ {time
+ .setLocale(locale)
+ .setZone(zone)
+ .toLocaleString(DateTime.TIME_WITH_SECONDS)}
+
+
+ );
+};
+
+export default Clock;
diff --git a/src/components/Clocks.css.ts b/src/components/Clocks.css.ts
new file mode 100644
index 00000000..0dcf2b0b
--- /dev/null
+++ b/src/components/Clocks.css.ts
@@ -0,0 +1,22 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const clocks = style({
+ display: "flex",
+ flexBasis: "100%",
+ gap: theme.spacing.m,
+ flexWrap: "wrap"
+});
+
+export const clock = style({
+ flexShrink: 5,
+ flexGrow: 5,
+ minWidth: "10rem",
+
+ borderStyle: "solid",
+ borderWidth: theme.borderWidth.thicc,
+ borderColor: theme.colors.black,
+ borderRadius: theme.borderRadius.plump,
+ padding: theme.spacing.s,
+ backgroundColor: "rgb(255 255 255 / .5)"
+});
diff --git a/src/components/Clocks.tsx b/src/components/Clocks.tsx
new file mode 100644
index 00000000..36ed48ec
--- /dev/null
+++ b/src/components/Clocks.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { FC } from "react";
+import Clock from "./Clock";
+import * as styles from "./Clocks.css";
+import useTime from "@/hooks/useTime";
+
+type Props = {
+ serverTime: string;
+};
+
+const setup = [
+ {
+ zone: "America/Montreal",
+ name: "Montreal",
+ locale: "en-ca"
+ },
+ {
+ zone: "Europe/London",
+ name: "Lontoo",
+ locale: "en-gb"
+ },
+ {
+ zone: "Europe/Helsinki",
+ name: "Helsinki",
+ locale: "fi-fi"
+ },
+ {
+ zone: "Asia/Tokyo",
+ name: "Tokio",
+ locale: "ja-jp"
+ }
+];
+
+const Clocks: FC = ({ serverTime }) => {
+ const time = useTime(serverTime);
+
+ return (
+
+ {setup.map((setup) => {
+ return (
+
+
+
+ );
+ })}
+
+ );
+};
+
+export default Clocks;
diff --git a/src/components/Currencies.css.ts b/src/components/Currencies.css.ts
new file mode 100644
index 00000000..03526d8e
--- /dev/null
+++ b/src/components/Currencies.css.ts
@@ -0,0 +1,6 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const currenciesClass = style({
+ marginBlock: theme.spacing.m
+});
diff --git a/src/components/Currencies.tsx b/src/components/Currencies.tsx
new file mode 100644
index 00000000..c68666e4
--- /dev/null
+++ b/src/components/Currencies.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { EnrichedCurrencyRates, getCurrencyRates } from "@/services/currency";
+import { FC, memo, useCallback, useDeferredValue, useState } from "react";
+
+import * as styles from "./Currencies.css";
+import CurrenciesList from "./CurrenciesList";
+import { DateTime } from "luxon";
+import { YEAR_MONTH_DAY } from "@/services/date";
+
+type Props = {
+ rates: EnrichedCurrencyRates;
+};
+
+const Currencies: FC = ({ rates }) => {
+ const [filter, setFilter] = useState("");
+
+ const [currentRates, setCurrentRates] = useState(rates);
+
+ const changeDate = useCallback(
+ async (dateModifier: number) => {
+ const currentDate = currentRates.date;
+ const dt = DateTime.fromISO(currentDate);
+ const nextDate = dt.plus({ days: dateModifier }).toFormat(YEAR_MONTH_DAY);
+
+ try {
+ const rates = await getCurrencyRates(nextDate);
+ setCurrentRates(rates);
+ } catch (e) {
+ console.error(e);
+ setCurrentRates({ date: nextDate, rates: {} });
+ }
+ },
+ [currentRates.date]
+ );
+
+ const debounced = useDeferredValue(filter);
+
+ return (
+
+
Valuutat
+
+
+
+ {currentRates.date}
+
+
+
+
+ {
+ setFilter(e.target.value);
+ }}
+ />
+
+
+
+
+ );
+};
+
+export default memo(Currencies);
diff --git a/src/components/CurrenciesList.tsx b/src/components/CurrenciesList.tsx
new file mode 100644
index 00000000..683794e4
--- /dev/null
+++ b/src/components/CurrenciesList.tsx
@@ -0,0 +1,65 @@
+import { CurrencyRecord, EnrichedCurrencyRates } from "@/services/currency";
+import { doNothing } from "@/services/util";
+import { FC, memo } from "react";
+
+type Props = {
+ rates: EnrichedCurrencyRates;
+ filter: string;
+};
+
+const formatter = new Intl.NumberFormat("fi", {
+ maximumFractionDigits: 2
+});
+
+const filtrado = (filter: string, key: string, record: CurrencyRecord) => {
+ if (!filter) {
+ return true;
+ }
+
+ if (key.toLocaleLowerCase().includes(filter.toLocaleLowerCase())) {
+ return true;
+ }
+
+ if (!record.meta) {
+ return false;
+ }
+
+ return record.meta.currency
+ .toLocaleLowerCase()
+ .includes(filter.toLocaleLowerCase());
+};
+
+const CurrenciesList: FC = ({ rates, filter }) => {
+ doNothing(100);
+
+ if (Object.entries(rates.rates).length === 0) {
+ return Tällä päivälle ei löytynyt valuuttakursseja.
;
+ }
+
+ return (
+
+
+
+ | lyhenne |
+ nimi |
+ euroja |
+
+
+
+ {Object.entries(rates.rates)
+ .filter(([key, value]) => filtrado(filter, key, value))
+ .map(([key, value]) => {
+ return (
+
+ | {key} |
+ {value.meta?.currency || key} |
+ {formatter.format(value.value)} |
+
+ );
+ })}
+
+
+ );
+};
+
+export default memo(CurrenciesList);
diff --git a/src/components/Footer.css.ts b/src/components/Footer.css.ts
new file mode 100644
index 00000000..fcb9c9c5
--- /dev/null
+++ b/src/components/Footer.css.ts
@@ -0,0 +1,12 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const footer = style({
+ marginBlockStart: theme.spacing.m,
+ paddingBlockStart: theme.spacing.m,
+ paddingBlockEnd: theme.spacing.xl,
+ paddingInline: theme.spacing.m,
+ textAlign: "center",
+ color: theme.colors.primary,
+ backgroundColor: theme.colors.black
+});
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 00000000..97574947
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,12 @@
+import { FC } from "react";
+import * as styles from "./Footer.css";
+
+const Footer: FC = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/Header.css.ts b/src/components/Header.css.ts
new file mode 100644
index 00000000..2156c103
--- /dev/null
+++ b/src/components/Header.css.ts
@@ -0,0 +1,20 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const header = style({
+ paddingBlock: theme.spacing.s,
+ paddingInline: theme.spacing.s,
+ textAlign: "center",
+ backgroundColor: theme.colors.primary,
+ color: theme.colors.black,
+ borderBlockEndStyle: "solid",
+ borderBlockEndColor: theme.colors.black,
+ borderBlockEndWidth: "1px",
+ fontSize: "xxx-large"
+});
+
+export const kvaak = style({
+ verticalAlign: "middle",
+ width: "50px",
+ marginRight: theme.spacing.s
+});
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 00000000..28de2ce2
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,15 @@
+import { FC } from "react";
+import * as styles from "./Header.css";
+
+import kvaak from "../assets/duckling.png";
+
+const Header: FC = () => {
+ return (
+
+
+ Kvauppalehti
+
+ );
+};
+
+export default Header;
diff --git a/src/components/Headline.css.ts b/src/components/Headline.css.ts
new file mode 100644
index 00000000..702f3333
--- /dev/null
+++ b/src/components/Headline.css.ts
@@ -0,0 +1,11 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const headline = style({
+ marginBlockStart: theme.spacing.s,
+ marginBlockEnd: theme.spacing.m,
+
+ ":last-of-type": {
+ marginBlockEnd: theme.spacing.m
+ }
+});
diff --git a/src/components/Headline.tsx b/src/components/Headline.tsx
new file mode 100644
index 00000000..476b7653
--- /dev/null
+++ b/src/components/Headline.tsx
@@ -0,0 +1,30 @@
+import { QuarticleType } from "@/services/quarticle";
+import Link from "next/link";
+import { FC } from "react";
+import * as styles from "./Headline.css";
+import { DateTime } from "luxon";
+
+type Props = {
+ quarticle: QuarticleType;
+};
+
+const Headline: FC = ({ quarticle }) => {
+ const publishedDt = DateTime.fromISO(quarticle.publishedAt)
+ .setLocale("fi")
+ .setZone("Europe/Helsinki");
+
+ return (
+
+
+
+ {quarticle.headline} (
+ {publishedDt.toLocaleString(DateTime.DATETIME_MED)})
+
+
+
+
{quarticle.lead}
+
+ );
+};
+
+export default Headline;
diff --git a/src/components/Headlines.tsx b/src/components/Headlines.tsx
new file mode 100644
index 00000000..26b3eb9c
--- /dev/null
+++ b/src/components/Headlines.tsx
@@ -0,0 +1,19 @@
+import { QuarticleType } from "@/services/quarticle";
+import { FC, memo } from "react";
+import Headline from "./Headline";
+
+type Props = {
+ quarticles: QuarticleType[];
+};
+
+const Headlines: FC = ({ quarticles }) => {
+ return (
+
+ {quarticles.map((q) => {
+ return ;
+ })}
+
+ );
+};
+
+export default memo(Headlines);
diff --git a/src/components/LatestQuarticles.tsx b/src/components/LatestQuarticles.tsx
new file mode 100644
index 00000000..dbe20ca1
--- /dev/null
+++ b/src/components/LatestQuarticles.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { QuarticleType } from "@/services/quarticle";
+import { FC, memo, useMemo } from "react";
+import Headlines from "./Headlines";
+
+type Props = {
+ quarticles: QuarticleType[];
+};
+
+const isImportant = (q: QuarticleType) => {
+ return q.priority === "high";
+};
+
+const LatestQuarticles: FC = ({ quarticles }) => {
+ const [importantQuarticles, normalQuarticles] = useMemo(() => {
+ return [
+ quarticles.filter((q) => isImportant(q)),
+ quarticles.filter((q) => !isImportant(q))
+ ];
+ }, [quarticles]);
+
+ return (
+
+
Suur-kvartikkelit
+
+
+ Tavan kvartikkelit
+
+
+ );
+};
+
+export default memo(LatestQuarticles);
diff --git a/src/components/Layout.css.ts b/src/components/Layout.css.ts
new file mode 100644
index 00000000..3a400683
--- /dev/null
+++ b/src/components/Layout.css.ts
@@ -0,0 +1,16 @@
+import { globalStyle } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+import bg from "../assets/duckling-pattern.png";
+
+globalStyle("html", {
+ fontFamily: theme.fontFamilies.body,
+ fontSize: theme.fontSizes.body,
+ backgroundImage: `url(${bg.src})`
+});
+
+globalStyle("body", {
+ marginBlock: 0,
+ marginInline: 0,
+ backgroundColor: "rgb(255 255 255 / .50)"
+});
diff --git a/src/components/Main.css.ts b/src/components/Main.css.ts
new file mode 100644
index 00000000..27aefce7
--- /dev/null
+++ b/src/components/Main.css.ts
@@ -0,0 +1,7 @@
+import { style } from "@vanilla-extract/css";
+import theme from "./theme.css";
+
+export const main = style({
+ paddingInline: theme.spacing.m,
+ containerType: "inline-size"
+});
diff --git a/src/components/Main.tsx b/src/components/Main.tsx
new file mode 100644
index 00000000..38a13dcd
--- /dev/null
+++ b/src/components/Main.tsx
@@ -0,0 +1,12 @@
+import { FC, ReactNode } from "react";
+import * as styles from "./Main.css";
+
+type Props = {
+ children: ReactNode;
+};
+
+const Main: FC = ({ children }) => {
+ return {children};
+};
+
+export default Main;
diff --git a/src/components/Provider.tsx b/src/components/Provider.tsx
new file mode 100644
index 00000000..6667f8cf
--- /dev/null
+++ b/src/components/Provider.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import { FC, ReactNode, useRef } from "react";
+
+type Props = {
+ children: ReactNode;
+};
+
+const Provider: FC = ({ children }) => {
+ const queryClientRef = useRef(new QueryClient());
+
+ return (
+
+
+ {children}
+
+ );
+};
+
+export default Provider;
diff --git a/src/components/comments/Comment.css.ts b/src/components/comments/Comment.css.ts
new file mode 100644
index 00000000..9a79936d
--- /dev/null
+++ b/src/components/comments/Comment.css.ts
@@ -0,0 +1,6 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const comment = style({
+ marginBlock: theme.spacing.s
+});
diff --git a/src/components/comments/Comment.tsx b/src/components/comments/Comment.tsx
new file mode 100644
index 00000000..5f1ca108
--- /dev/null
+++ b/src/components/comments/Comment.tsx
@@ -0,0 +1,20 @@
+import { CommentType } from "@/services/comments";
+import { FC } from "react";
+import * as styles from "./Comment.css";
+
+type Props = {
+ comment: CommentType;
+};
+
+const Comment: FC = ({ comment }) => {
+ return (
+
+
+ {comment.email}
+
+
{comment.comment}
+
+ );
+};
+
+export default Comment;
diff --git a/src/components/comments/Comments.css.ts b/src/components/comments/Comments.css.ts
new file mode 100644
index 00000000..bca778a4
--- /dev/null
+++ b/src/components/comments/Comments.css.ts
@@ -0,0 +1,6 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const comments = style({
+ marginBlock: theme.spacing.m
+});
diff --git a/src/components/comments/Comments.tsx b/src/components/comments/Comments.tsx
new file mode 100644
index 00000000..339d986e
--- /dev/null
+++ b/src/components/comments/Comments.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { NewCommentType, getComments, postComment } from "@/services/comments";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { FC } from "react";
+import Spinner from "../debug/Spinner";
+import * as styles from "./Comments.css";
+import CommentsForm from "./CommentsForm";
+import { reverse, sortBy } from "ramda";
+import Comment from "./Comment";
+
+type Props = {
+ quarticleId: string;
+};
+
+const Comments: FC = ({ quarticleId }) => {
+ const queryClient = useQueryClient();
+ const { data, isLoading } = useQuery({
+ queryKey: ["comments", quarticleId],
+ queryFn: () => {
+ return getComments(quarticleId);
+ }
+ });
+
+ const mutation = useMutation({
+ mutationFn: (data: NewCommentType) => {
+ return postComment(quarticleId, data);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["comments", quarticleId] });
+ }
+ });
+
+ const sorted = reverse(
+ sortBy((c) => {
+ return c.publishedAt;
+ }, data || [])
+ );
+
+ return (
+
+ Comments for #{quarticleId}
+
+
+
+ {isLoading && }
+
+ {sorted.map((comment) => {
+ return ;
+ })}
+
+ );
+};
+
+export default Comments;
diff --git a/src/components/comments/CommentsForm.tsx b/src/components/comments/CommentsForm.tsx
new file mode 100644
index 00000000..5dd85e1d
--- /dev/null
+++ b/src/components/comments/CommentsForm.tsx
@@ -0,0 +1,57 @@
+import { NewCommentType, commentSchema } from "@/services/comments";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { FC } from "react";
+import { useForm } from "react-hook-form";
+import Button from "../duck-ui/Button";
+import Input from "../duck-ui/Input";
+
+type Props = {
+ postComment: (comment: NewCommentType) => void;
+};
+
+const CommentsForm: FC = ({ postComment }) => {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isValid }
+ } = useForm({
+ mode: "onChange",
+ resolver: zodResolver(commentSchema),
+ defaultValues: {
+ email: "",
+ comment: ""
+ }
+ });
+
+ // todo: unique ids
+
+ return (
+
+ );
+};
+
+export default CommentsForm;
diff --git a/src/components/duck-ui/Button.css.ts b/src/components/duck-ui/Button.css.ts
new file mode 100644
index 00000000..492ba76e
--- /dev/null
+++ b/src/components/duck-ui/Button.css.ts
@@ -0,0 +1,6 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const button = style({
+ borderRadius: theme.borderRadius.plump
+});
diff --git a/src/components/duck-ui/Button.tsx b/src/components/duck-ui/Button.tsx
new file mode 100644
index 00000000..0183328a
--- /dev/null
+++ b/src/components/duck-ui/Button.tsx
@@ -0,0 +1,19 @@
+import { ComponentProps, ForwardRefRenderFunction, forwardRef } from "react";
+import * as styles from "./Button.css";
+
+type Props = ComponentProps<"button"> & {
+ variant?: "primary" | "secondary";
+};
+
+const Button: ForwardRefRenderFunction = (
+ { children, ...rest },
+ ref
+) => {
+ return (
+
+ );
+};
+
+export default forwardRef(Button);
diff --git a/src/components/duck-ui/Input.css.ts b/src/components/duck-ui/Input.css.ts
new file mode 100644
index 00000000..8524cc80
--- /dev/null
+++ b/src/components/duck-ui/Input.css.ts
@@ -0,0 +1,7 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const input = style({
+ borderRadius: theme.borderRadius.plump,
+ fontFamily: "inherit"
+});
diff --git a/src/components/duck-ui/Input.tsx b/src/components/duck-ui/Input.tsx
new file mode 100644
index 00000000..8cad433c
--- /dev/null
+++ b/src/components/duck-ui/Input.tsx
@@ -0,0 +1,19 @@
+import { ComponentProps, ForwardRefRenderFunction, forwardRef } from "react";
+import * as styles from "./Input.css";
+
+type Props = ComponentProps<"input"> & {
+ variant?: "primary" | "secondary";
+};
+
+const Input: ForwardRefRenderFunction = (
+ { children, ...rest },
+ ref
+) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export default forwardRef(Input);
diff --git a/src/components/pages/IndexPageView.css.ts b/src/components/pages/IndexPageView.css.ts
new file mode 100644
index 00000000..eeaca75e
--- /dev/null
+++ b/src/components/pages/IndexPageView.css.ts
@@ -0,0 +1,49 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const grid = style({
+ display: "grid",
+ gap: theme.spacing.m,
+
+ gridTemplateColumns: "1fr 1fr 1fr",
+ gridTemplateRows: "auto",
+
+ gridTemplateAreas: `
+ "clock clock clock"
+ "latest latest currency"
+ `,
+
+ "@container": {
+ "(width <= 50rem)": {
+ gridTemplateColumns: "1fr",
+ gridTemplateRows: "auto",
+
+ gridTemplateAreas: `
+ "clock"
+ "latest"
+ "currency"
+ `
+ }
+ }
+});
+
+export const clock = style({
+ borderRadius: theme.borderRadius.plump,
+ padding: theme.spacing.s,
+ backgroundColor: "rgb(0 255 0 / .10)",
+ gridArea: "clock"
+});
+
+export const currencies = style({
+ borderRadius: theme.borderRadius.plump,
+ padding: theme.spacing.s,
+ backgroundColor: "rgb(255 0 0 / .10)",
+ gridArea: "currency"
+});
+
+export const latest = style({
+ borderRadius: theme.borderRadius.plump,
+ padding: theme.spacing.s,
+ backgroundColor: "rgb(0 0 255 / .10)",
+ gridArea: "latest"
+});
diff --git a/src/components/pages/IndexPageView.tsx b/src/components/pages/IndexPageView.tsx
new file mode 100644
index 00000000..008a2053
--- /dev/null
+++ b/src/components/pages/IndexPageView.tsx
@@ -0,0 +1,44 @@
+import { EnrichedCurrencyRates } from "@/services/currency";
+import { GetQuarticlesResponse } from "@/services/quarticle";
+import Clocks from "../Clocks";
+import Currencies from "../Currencies";
+import LatestQuarticles from "../LatestQuarticles";
+import * as styles from "./IndexPageView.css";
+
+type Props = {
+ serverTime: string;
+ rates: EnrichedCurrencyRates;
+ latestQuarticles: GetQuarticlesResponse;
+};
+
+export default function IndexPageView({
+ rates,
+ latestQuarticles,
+ serverTime
+}: Props) {
+ return (
+
+
Tervetuloa Kvauppalehden sivuille
+
+ {/*
*/}
+
+
Kvauppalehti on Internetin johtava kvauppapaikka!!!
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/quarticle/HeadingBlock.tsx b/src/components/quarticle/HeadingBlock.tsx
new file mode 100644
index 00000000..e8e1a5b8
--- /dev/null
+++ b/src/components/quarticle/HeadingBlock.tsx
@@ -0,0 +1,12 @@
+import { HeadingBlockType } from "@/services/quarticle";
+import { FC } from "react";
+
+type Props = {
+ block: HeadingBlockType;
+};
+
+const HeadingBlock: FC = ({ block }) => {
+ return {block.text}
;
+};
+
+export default HeadingBlock;
diff --git a/src/components/quarticle/ImageBlock.css.ts b/src/components/quarticle/ImageBlock.css.ts
new file mode 100644
index 00000000..7b3c1d18
--- /dev/null
+++ b/src/components/quarticle/ImageBlock.css.ts
@@ -0,0 +1,9 @@
+import { style } from "@vanilla-extract/css";
+import theme from "../theme.css";
+
+export const image = style({
+ width: "100%",
+ maxWidth: "100%",
+ display: "block",
+ marginBlock: theme.spacing.m
+});
diff --git a/src/components/quarticle/ImageBlock.tsx b/src/components/quarticle/ImageBlock.tsx
new file mode 100644
index 00000000..5c330f25
--- /dev/null
+++ b/src/components/quarticle/ImageBlock.tsx
@@ -0,0 +1,13 @@
+import { ImageBlockType } from "@/services/quarticle";
+import { FC } from "react";
+import * as styles from "./ImageBlock.css";
+
+type Props = {
+ block: ImageBlockType;
+};
+
+const ImageBlock: FC = ({ block }) => {
+ return
;
+};
+
+export default ImageBlock;
diff --git a/src/components/quarticle/MarkdownBlock.tsx b/src/components/quarticle/MarkdownBlock.tsx
new file mode 100644
index 00000000..1bf8b3f8
--- /dev/null
+++ b/src/components/quarticle/MarkdownBlock.tsx
@@ -0,0 +1,13 @@
+import { MarkdownBlockType } from "@/services/quarticle";
+import { FC } from "react";
+import Markdown from "react-markdown";
+
+type Props = {
+ block: MarkdownBlockType;
+};
+
+const MarkdownBlock: FC = ({ block }) => {
+ return {block.text};
+};
+
+export default MarkdownBlock;
diff --git a/src/components/quarticle/QuarticleRenderer.css.ts b/src/components/quarticle/QuarticleRenderer.css.ts
new file mode 100644
index 00000000..8df21a18
--- /dev/null
+++ b/src/components/quarticle/QuarticleRenderer.css.ts
@@ -0,0 +1,5 @@
+import { style } from "@vanilla-extract/css";
+
+export const lead = style({
+ fontSize: "x-large"
+});
diff --git a/src/components/quarticle/QuarticleRenderer.tsx b/src/components/quarticle/QuarticleRenderer.tsx
new file mode 100644
index 00000000..11ca0dc0
--- /dev/null
+++ b/src/components/quarticle/QuarticleRenderer.tsx
@@ -0,0 +1,39 @@
+import { QuarticleType } from "@/services/quarticle";
+import { FC } from "react";
+import HeadingBlock from "./HeadingBlock";
+import ImageBlock from "./ImageBlock";
+import MarkdownBlock from "./MarkdownBlock";
+import * as styles from "./QuarticleRenderer.css";
+import UnknownBlock from "./UnknownBlock";
+
+type Props = {
+ quarticle: QuarticleType;
+};
+
+const QuarticleRenderer: FC = ({ quarticle }) => {
+ return (
+
+ {quarticle.headline}
+
+ {quarticle.lead}
+
+ {quarticle.content.map((block, i) => {
+ switch (block.type) {
+ case "image":
+ return ;
+
+ case "markdown":
+ return ;
+
+ case "heading":
+ return ;
+
+ default:
+ return ;
+ }
+ })}
+
+ );
+};
+
+export default QuarticleRenderer;
diff --git a/src/components/quarticle/UnknownBlock.tsx b/src/components/quarticle/UnknownBlock.tsx
new file mode 100644
index 00000000..e6bd542a
--- /dev/null
+++ b/src/components/quarticle/UnknownBlock.tsx
@@ -0,0 +1,12 @@
+import { ContentBlockType } from "@/services/quarticle";
+import { FC } from "react";
+
+type Props = {
+ block: ContentBlockType;
+};
+
+const UnknownBlock: FC = ({ block }) => {
+ return {JSON.stringify(block)}
;
+};
+
+export default UnknownBlock;
diff --git a/src/components/theme.css.ts b/src/components/theme.css.ts
new file mode 100644
index 00000000..c8506109
--- /dev/null
+++ b/src/components/theme.css.ts
@@ -0,0 +1,43 @@
+import { createGlobalTheme } from "@vanilla-extract/css";
+
+const theme = createGlobalTheme(":root", {
+ fontFamilies: {
+ body: "Comic Sans Ms"
+ },
+
+ fontSizes: {
+ body: "18px"
+ },
+
+ borderRadius: {
+ sharp: "3px",
+ soft: "6px",
+ plump: "9px"
+ },
+
+ borderWidth: {
+ thin: "1px",
+ thicc: "5px"
+ },
+
+ colors: {
+ primary: "#ffff4d",
+ secondary: "#b3b336",
+ tertiary: "#b8ff4d",
+ quartiary: "#ffdb4d",
+ femtiary: "#ffdb4d",
+ black: "rgb(0, 0, 0)",
+ white: "rgb(255, 255, 255)"
+ },
+
+ spacing: {
+ none: "0",
+ xs: "0.5rem",
+ s: "1rem",
+ m: "2rem",
+ l: "4rem",
+ xl: "8rem"
+ }
+});
+
+export default theme;
diff --git a/src/components/welcome/BackendChecker.tsx b/src/components/welcome/BackendChecker.tsx
index 0ca621d5..6d2996fc 100644
--- a/src/components/welcome/BackendChecker.tsx
+++ b/src/components/welcome/BackendChecker.tsx
@@ -59,6 +59,11 @@ const BackendChecker: FC = () => {
contact the teacher.
+
+ Also open the browser dev console and assert that it has all kinds of
+ stuff. Warnings and whatnot!
+
+
{quarticles.length > 0 && (