Skip to content

feat: implement app #1

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 61 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
a02f368
feat: add color variables
johnnygerard Mar 2, 2025
2141181
feat: add spacing scale
johnnygerard Mar 2, 2025
62d2fdf
feat: add border radius scale
johnnygerard Mar 2, 2025
c44f479
feat: set font
johnnygerard Mar 2, 2025
df5edf9
feat: set page title and description
johnnygerard Mar 2, 2025
a40e72f
feat: add typography styles
johnnygerard Mar 2, 2025
b9dad31
feat: add canvas background colors
johnnygerard Mar 2, 2025
1f2f51c
feat: add support for dark and light themes
johnnygerard Mar 3, 2025
d60f260
feat: add theme-toggle.tsx
johnnygerard Mar 3, 2025
cef2e7a
feat: add background noise pattern
johnnygerard Mar 3, 2025
b56e1f7
feat: update favicon.png
johnnygerard Mar 3, 2025
5242971
feat: add logo.tsx
johnnygerard Mar 3, 2025
ee92ced
feat: use light and dark favicon variants
johnnygerard Mar 3, 2025
0769392
feat: add layout padding
johnnygerard Mar 3, 2025
a9e656b
feat: add header.tsx
johnnygerard Mar 3, 2025
8871c89
feat: update focus ring color
johnnygerard Mar 3, 2025
dda29e9
feat: update layout
johnnygerard Mar 3, 2025
100975a
feat: update not-found.tsx
johnnygerard Mar 3, 2025
199a1b9
feat: reset homepage
johnnygerard Mar 3, 2025
571d9a4
fix: adjust background pattern position
johnnygerard Mar 3, 2025
868e8e3
feat: add homepage heading
johnnygerard Mar 3, 2025
44389ca
refactor: remove custom spacing scale
johnnygerard Mar 3, 2025
d148182
feat: add app-textarea.tsx
johnnygerard Mar 3, 2025
672b3ca
feat: add app-checkbox.tsx
johnnygerard Mar 3, 2025
3e597fd
feat: add text-analyzer.tsx
johnnygerard Mar 3, 2025
323ff63
fix: placeholder not reacting to theme changes
johnnygerard Mar 4, 2025
7a3f1db
feat: add count-characters.ts
johnnygerard Mar 4, 2025
0699007
feat: add count-words.ts
johnnygerard Mar 4, 2025
8aa3a79
feat: add count-sentences.ts
johnnygerard Mar 4, 2025
81338d2
feat: use block layout for app-textarea.tsx
johnnygerard Mar 4, 2025
3c3e3c3
feat: set dark theme scrollbar color
johnnygerard Mar 4, 2025
972bc73
feat: add SVG shape components
johnnygerard Mar 4, 2025
892f386
feat: add text-counters.tsx
johnnygerard Mar 4, 2025
f5a977a
a11y: hide SVG shapes from screen readers
johnnygerard Mar 4, 2025
a8531a5
refactor: use Boolean attributes
johnnygerard Mar 4, 2025
4a2aea5
feat: add get-letter-stats.ts
johnnygerard Mar 4, 2025
2ac1775
a11y: use ul and li tags instead of div
johnnygerard Mar 4, 2025
06a0521
feat: add text-stats.tsx
johnnygerard Mar 4, 2025
e2f40ed
feat: add icon-chevron.tsx
johnnygerard Mar 4, 2025
251da26
feat: add toggle button to collapse and expand items
johnnygerard Mar 4, 2025
f0adbc7
feat: add reading-time.tsx
johnnygerard Mar 5, 2025
e576d6e
feat: add width transition to meter fill
johnnygerard Mar 5, 2025
f3aab0d
fix: bad link focus ring in not-found.tsx
johnnygerard Mar 5, 2025
c211181
feat: adjust button border-radius in text-stats.tsx
johnnygerard Mar 5, 2025
34be7c1
feat: add checkboxes and reading time
johnnygerard Mar 5, 2025
474df4f
feat: add z-index to text counters
johnnygerard Mar 5, 2025
2bfeafa
feat: exclude whitespace instead of single spaces
johnnygerard Mar 5, 2025
595366c
feat: add logic to exclude spaces checkbox
johnnygerard Mar 5, 2025
fc0da66
feat: update counter text when space is excluded
johnnygerard Mar 5, 2025
33991d7
feat: prevent text wrapping in text-counters.tsx
johnnygerard Mar 5, 2025
e91e1aa
feat: use grapheme segmenter for count-characters.ts
johnnygerard Mar 5, 2025
4ab2746
refactor: extract constant WHITESPACE
johnnygerard Mar 5, 2025
bc7204f
refactor: use destructuring assignment
johnnygerard Mar 5, 2025
e88967f
feat: add NFD normalization
johnnygerard Mar 5, 2025
1fd4d54
feat: exclude only spaces instead of whitespace
johnnygerard Mar 5, 2025
abc7d31
feat: add app-textarea.tsx invalid state
johnnygerard Mar 5, 2025
fa9bb12
feat: add character limit number input
johnnygerard Mar 5, 2025
d4a4c7c
feat: add transitions to checkbox and textarea
johnnygerard Mar 5, 2025
eae83ec
feat: add fadeIn animation to textarea error
johnnygerard Mar 5, 2025
35fa74c
refactor: use Tailwind utility class size
johnnygerard Mar 5, 2025
206f161
feat: add transition to canvas background color
johnnygerard Mar 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/asset/image/dark-noise.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/asset/image/favicon-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/asset/image/favicon-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/asset/image/favicon.png
Binary file not shown.
Binary file added public/asset/image/light-noise.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions src/app/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"use server";
import { THEME_KEY } from "@/constants";
import { THEME } from "@/type/theme";
import { cookies } from "next/headers";

export const loadTheme = async (): Promise<THEME> => {
const cookieStore = await cookies();
const theme = cookieStore.get(THEME_KEY)?.value;

return theme === THEME.DARK || theme === THEME.LIGHT ? theme : THEME.SYSTEM;
};
70 changes: 68 additions & 2 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,50 @@

a,
button:enabled,
input {
input,
textarea {
/* `outline` is set to `none` to avoid conflicts with React Aria focus ring */
outline: none;
}
}

@layer components {
/* Text Variants */
.tv_display {
@apply text-[2.5rem]/[1] font-bold -tracking-[0.0625rem] tb:text-[4rem];
}

.tv_medium {
@apply text-[1.5rem]/[1.3] font-semibold -tracking-[0.0625rem];
}

.tv_small {
@apply text-[1rem]/[1.3] font-normal -tracking-[0.0375rem];
}
}

:root {
@variant dark {
scrollbar-color: var(--color-neutral-500) var(--color-neutral-700);
}
}

@custom-variant dark {
&:root[data-theme="dark"],
:root[data-theme="dark"] & {
/*noinspection CssInvalidAtRule*/
@slot;
}

&:root[data-theme="system"],
:root[data-theme="system"] & {
@media (prefers-color-scheme: dark) {
/*noinspection CssInvalidAtRule*/
@slot;
}
}
}

/* Disable all animations when user prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
:root {
Expand All @@ -29,7 +67,35 @@
--breakpoint-tb: 48em; /* Tablet: 768px */
--breakpoint-dt: 90em; /* Desktop: 1440px */

--font-sans: var(--font-geist-sans);
--color-neutral-0: #ffffff;
--color-neutral-100: #f2f2f7;
--color-neutral-200: #e4e4ef;
--color-neutral-600: #404254;
--color-neutral-700: #2a2b37;
--color-neutral-800: #21222c;
--color-neutral-900: #12131a;

--color-purple-300: #debafc;
--color-purple-400: #d3a0fa;
--color-purple-500: #c27cf8;

--color-yellow-400: #ffb844;
--color-yellow-500: #ff9f00;

--color-orange-400: #fa9a82;
--color-orange-500: #fe8159;
--color-orange-800: #da3701;

--font-sans: var(--font-dm-sans);

--radius-4: 0.25rem;
--radius-6: 0.375rem;
--radius-8: 0.5rem;
--radius-10: 0.625rem;
--radius-12: 0.75rem;
--radius-16: 1rem;
--radius-20: 1.25rem;
--radius-24: 1.5rem;

--spacing: 0.25rem;

Expand Down
59 changes: 46 additions & 13 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { loadTheme } from "@/app/actions";
import AppRouterProvider from "@/component/app-router-provider";
import Header from "@/component/header";
import Noscript from "@/component/noscript";
import ThemeProvider from "@/component/theme-provider";
import { cn } from "@/util/cn";
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import { DM_Sans } from "next/font/google";
import "./globals.css";
import { memo, ReactNode } from "react";

const geistSans = Geist({
const dmSans = DM_Sans({
display: "swap",
subsets: ["latin"],
variable: "--font-geist-sans",
variable: "--font-dm-sans",
});

const APP_NAME = "placeholder";
const DESCRIPTION = "placeholder";
const APP_NAME = "Character counter";
const DESCRIPTION = `Frontend Mentor challenge: ${APP_NAME}`;

export const metadata: Metadata = {
metadataBase: new URL("https://example.com/placeholder"),
Expand All @@ -27,7 +30,15 @@ export const metadata: Metadata = {
rel: "icon",
sizes: "32x32",
type: "image/png",
url: "/asset/image/favicon.png",
url: "/asset/image/favicon-light.png",
media: "(prefers-color-scheme: light)",
},
{
rel: "icon",
sizes: "32x32",
type: "image/png",
url: "/asset/image/favicon-dark.png",
media: "(prefers-color-scheme: dark)",
},
],
openGraph: {
Expand All @@ -43,14 +54,36 @@ type Props = {
children: ReactNode;
};

const RootLayout = ({ children }: Props) => {
const RootLayout = async ({ children }: Props) => {
const theme = await loadTheme();

return (
<html
className={cn(geistSans.variable, "font-sans antialiased")}
lang="en-US"
>
<body>
<AppRouterProvider>{children}</AppRouterProvider>
<html className={cn(dmSans.variable)} data-theme={theme} lang="en-US">
<body
className={cn(
"font-sans font-normal not-italic antialiased",
"text-[1.25rem]/[1.4] -tracking-[0.0375rem]",
"text-neutral-900 dark:text-neutral-200",
"bg-neutral-0 transition-[background-color] dark:bg-neutral-900",
"flex justify-center p-4 tb:px-8 dt:py-8",
)}
>
<div
aria-hidden
className={cn(
"fixed top-0 left-0 -z-10 h-screen w-screen opacity-50",
"bg-[url(/asset/image/light-noise.png)]",
"dark:bg-[url(/asset/image/dark-noise.png)]",
)}
/>
<AppRouterProvider>
<ThemeProvider initialTheme={theme}>
<div className="w-full max-w-248 dt:w-248">
<Header />
<main>{children}</main>
</div>
</ThemeProvider>
</AppRouterProvider>
<Noscript />
</body>
</html>
Expand Down
12 changes: 6 additions & 6 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ export const metadata: Metadata = {

const NotFound = () => {
return (
<div className="grid min-h-screen place-items-center">
<div className="text-center">
<h1>404 Not Found</h1>
<p>Sorry, the page you are looking for does not exist.</p>
<AppLink href="/">Back to Home</AppLink>
</div>
<div className="mt-8 flex flex-col items-center text-center">
<h1 className="tv_medium">404 Not Found</h1>
<p>Sorry, the page you are looking for does not exist.</p>
<AppLink className="mt-6 rounded-6" href="/">
Back to Home
</AppLink>
</div>
);
};
Expand Down
16 changes: 13 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import TextAnalyzer from "@/component/text-analyzer";
import { cn } from "@/util/cn";
import { memo } from "react";

const HomePage = () => {
return (
<div className="grid min-h-screen place-items-center">
<h1>Deployment successful!</h1>
</div>
<>
<h1
className={cn(
"tv_display mt-14 dark:text-neutral-100",
"text-center whitespace-pre-line",
)}
>
Analyze your text{"\n"}in real&#x2011;time.
</h1>
<TextAnalyzer />
</>
);
};

Expand Down
50 changes: 50 additions & 0 deletions src/component/app-checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use client";
import { cn } from "@/util/cn";
import { memo } from "react";
import { Checkbox, CheckboxProps } from "react-aria-components";

const AppCheckbox = ({ children, ...props }: CheckboxProps) => {
return (
<Checkbox {...props}>
{({ isHovered, isFocusVisible, isSelected }) => (
<div className={cn("flex cursor-pointer items-center gap-2.5")}>
<div
aria-hidden
className={cn(
"grid size-4 place-items-center rounded-4",
"transition-[background-color,border-color,box-shadow]",
isFocusVisible &&
"shadow-[0_0_0_2px_var(--color-neutral-0),0_0_0_4px_var(--color-purple-400)]",
isSelected && (isHovered ? "bg-purple-500" : "bg-purple-400"),
!isSelected && [
"border border-neutral-900 dark:border-neutral-200",
isHovered && "border-neutral-600 dark:border-neutral-0",
isFocusVisible && "border-neutral-200 bg-neutral-0",
],
)}
>
{isSelected && (
<svg
className="size-3 stroke-neutral-900"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M10 3L4.5 8.5L2 6"
strokeWidth="1.6666"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
<span className="tv_small">
<>{children}</>
</span>
</div>
)}
</Checkbox>
);
};

export default memo(AppCheckbox);
2 changes: 1 addition & 1 deletion src/component/app-focus-ring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const AppFocusRing = (props: FocusRingProps) => {
return (
<FocusRing
focusRingClass={cn(
"animate-focus-ring outline-2 outline-offset-2 outline-blue-500",
"animate-focus-ring outline-2 outline-offset-2 outline-purple-400",
)}
{...props}
/>
Expand Down
72 changes: 72 additions & 0 deletions src/component/app-textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client";
import IconInfoCircle from "@/component/svg/icon-info-circle";
import { cn } from "@/util/cn";
import { Dispatch, memo, SetStateAction } from "react";
import { FieldError, TextArea, TextField } from "react-aria-components";

type Props = {
characterCount: number;
characterLimit: number;
hasCharacterLimit: boolean;
text: string;
setText: Dispatch<SetStateAction<string>>;
};

const AppTextarea = ({
characterCount,
characterLimit,
hasCharacterLimit,
text,
setText,
}: Props) => {
return (
<TextField
aria-label="Text to analyze"
defaultValue={text}
onChange={setText}
isInvalid={hasCharacterLimit && characterCount > characterLimit}
>
<TextArea
placeholder="Start typing here… (or paste your text)"
className={({ isFocused, isHovered, isInvalid }) => {
if (isFocused) isHovered = false;

return cn(
"block h-50 w-full resize-none rounded-12 border-2",
"transition-[background-color,border-color,box-shadow]",
"mt-10 p-3 tb:p-5 dt:mt-12",
"text-neutral-700 dark:text-neutral-200",
// Use of `placeholder-current` is avoided because it does not always
// react to theme changes.
"placeholder-neutral-700/50 dark:placeholder-neutral-200/50",
isHovered
? "bg-neutral-200 dark:bg-neutral-700"
: "bg-neutral-100 dark:bg-neutral-800",
isInvalid
? "border-orange-800 dark:border-orange-500"
: isFocused
? "border-purple-500"
: isHovered
? "border-neutral-200 dark:border-neutral-600"
: "border-neutral-200 dark:border-neutral-700",
isFocused &&
(isInvalid
? "shadow-[0_0_8px] shadow-orange-800 dark:shadow-orange-500"
: "shadow-[0_0_10px] shadow-purple-400"),
);
}}
/>
<FieldError
className={cn(
"tv_small mt-3 flex animate-fade-in items-center gap-2",
"text-orange-800 dark:text-orange-500",
)}
>
<IconInfoCircle className="h-3.75 w-3.5" />
Limit reached! Your text exceeds {characterLimit} characters.
</FieldError>
</TextField>
);
};

export default memo(AppTextarea);
14 changes: 14 additions & 0 deletions src/component/header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Logo from "@/component/logo";
import ThemeToggle from "@/component/theme-toggle";
import { memo } from "react";

const Header = () => {
return (
<header className="flex items-center justify-between">
<Logo />
<ThemeToggle />
</header>
);
};

export default memo(Header);
18 changes: 18 additions & 0 deletions src/component/logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Logomark from "@/component/svg/logomark";
import { memo } from "react";

const Logo = () => {
return (
<div className="flex items-center gap-2.25 tb:gap-3">
<Logomark
aria-hidden
className="h-7.5 fill-purple-400 tb:h-10 dark:fill-purple-500"
/>
<span className="tv_medium max-tb:text-[1.125rem] max-tb:-tracking-[0.04688rem]">
Character Counter
</span>
</div>
);
};

export default memo(Logo);
Loading
Loading