Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3,770 changes: 3,770 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

39 changes: 22 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { ArrowSquareOut, ChatCircle, ArrowUp, Clock } from '@phosphor-icons/react'
import ThemePicker from './ThemePicker'

interface AlgoliaStory {
objectID: string
Expand Down Expand Up @@ -94,36 +95,39 @@ function App() {

if (loading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<p className="text-neutral-400 text-sm">Loading...</p>
<div className="min-h-screen bg-theme-page-bg flex items-center justify-center">
<p className="text-theme-meta text-sm">Loading...</p>
</div>
)
}

if (error) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<p className="text-neutral-500 text-sm">{error}</p>
<div className="min-h-screen bg-theme-page-bg flex items-center justify-center">
<p className="text-theme-subheading text-sm">{error}</p>
</div>
)
}

return (
<div className="min-h-screen bg-neutral-50 relative">
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-orange-200 to-transparent pointer-events-none"></div>
<div className="min-h-screen bg-theme-page-bg relative transition-colors duration-300">
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-theme-page-gradient to-transparent pointer-events-none transition-colors duration-300"></div>
<div className="max-w-3xl mx-auto px-6 py-12 relative">
<header className="mb-8">
<h1 className="text-4xl font-extrabold text-slate-900 tracking-tight">
Calm HN
</h1>
<p className="text-slate-500 text-[10px] mt-2 uppercase tracking-wider">
Top stories from the last three months
</p>
<header className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-4xl font-extrabold text-theme-heading tracking-tight">
Calm HN
</h1>
<p className="text-theme-subheading text-[10px] mt-2 uppercase tracking-wider">
Top stories from the last three months
</p>
</div>
<ThemePicker />
</header>

<div className="space-y-6">
{stories.map((story, index) => (
<article key={story.id} className="group -mx-3 px-3 py-3 rounded-lg hover:bg-slate-100 transition-colors duration-300 relative">
<article key={story.id} className="group -mx-3 px-3 py-3 rounded-lg hover:bg-theme-card-hover transition-colors duration-300 relative">
<a
href={story.url || `https://news.ycombinator.com/item?id=${story.id}`}
target="_blank"
Expand All @@ -132,10 +136,10 @@ function App() {
aria-label={story.title}
/>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 relative z-10 pointer-events-none">
<span className="bg-slate-200 text-slate-500 text-[10px] leading-none font-medium px-2 py-0.5 rounded-full flex-shrink-0 self-center mt-px group-hover:bg-orange-200 group-hover:text-slate-600 transition-colors group-hover:duration-[750ms] duration-300">
<span className="bg-theme-badge-bg text-theme-badge-text text-[10px] leading-none font-medium px-2 py-0.5 rounded-full flex-shrink-0 self-center mt-px group-hover:bg-theme-badge-hover-bg group-hover:text-theme-badge-hover-text transition-colors group-hover:duration-[750ms] duration-300">
{index + 1}
</span>
<h2 className="text-slate-900 text-lg leading-relaxed">
<h2 className="text-theme-title text-lg leading-relaxed">
<span className="inline-flex items-baseline gap-1.5">
{story.title}
{story.url && (
Expand All @@ -144,7 +148,7 @@ function App() {
</span>
</h2>
<div></div>
<div className="flex items-center gap-3 text-xs text-slate-400 group-hover:text-slate-500 transition-colors duration-300">
<div className="flex items-center gap-3 text-xs text-theme-meta group-hover:text-theme-meta-hover transition-colors duration-300">
<span className="flex items-center gap-1">
<ArrowUp size={12} weight="regular" className="opacity-60" />
{story.score}
Expand Down Expand Up @@ -185,3 +189,4 @@ function App() {
}

export default App
const x: number = 'fail';
36 changes: 36 additions & 0 deletions src/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useEffect, useState, type ReactNode } from 'react'
import { themes, STORAGE_KEY, type Theme } from './themeConfig'
import { ThemeContext } from './themes'

function getInitialTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && themes.some(t => t.id === stored)) {
return stored as Theme
}
} catch {
// localStorage unavailable
}
return 'default'
}

export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)

useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
try {
localStorage.setItem(STORAGE_KEY, theme)
} catch {
// localStorage unavailable
}
}, [theme])

const setTheme = (t: Theme) => setThemeState(t)

return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
60 changes: 60 additions & 0 deletions src/ThemePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState, useRef, useEffect } from 'react'
import { Palette, Check } from '@phosphor-icons/react'
import { themes } from './themeConfig'
import { useTheme } from './useTheme'

export default function ThemePicker() {
const { theme, setTheme } = useTheme()
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)

useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [])

return (
<div ref={containerRef} className="relative">
<button
onClick={() => setOpen(o => !o)}
className="p-1.5 rounded-md hover:bg-theme-card-hover text-theme-meta hover:text-theme-heading transition-colors duration-200"
aria-label="Change theme"
title="Change theme"
>
<Palette size={20} weight="regular" />
</button>

{open && (
<div className="absolute right-0 mt-2 bg-theme-picker-bg border border-theme-picker-border rounded-lg shadow-lg p-2 flex gap-1.5 z-50">
{themes.map(t => (
<button
key={t.id}
onClick={() => { setTheme(t.id); setOpen(false) }}
className="relative w-8 h-8 rounded-full border-2 transition-all duration-200 hover:scale-110 cursor-pointer flex items-center justify-center"
style={{
backgroundColor: t.swatch,
borderColor: theme === t.id ? 'var(--color-picker-check)' : 'transparent',
}}
aria-label={t.label}
title={t.label}
>
{theme === t.id && (
<Check
size={14}
weight="bold"
className={t.id === 'dark' ? 'text-white' : 'text-white'}
style={{ filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }}
/>
)}
</button>
))}
</div>
)}
</div>
)
}
97 changes: 97 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,101 @@

body {
font-family: "InterVariable", sans-serif;
}

/* Theme variables */
:root,
[data-theme="default"] {
--color-page-bg: oklch(0.985 0.002 75);
--color-page-gradient: oklch(0.899 0.061 71.3);
--color-heading: oklch(0.208 0.042 265.8);
--color-subheading: oklch(0.554 0.022 257.4);
--color-title: oklch(0.208 0.042 265.8);
--color-meta: oklch(0.704 0.022 261.1);
--color-meta-hover: oklch(0.554 0.022 257.4);
--color-badge-bg: oklch(0.899 0.013 261.1);
--color-badge-text: oklch(0.554 0.022 257.4);
--color-badge-hover-bg: oklch(0.899 0.061 71.3);
--color-badge-hover-text: oklch(0.446 0.03 256.8);
--color-card-hover: oklch(0.928 0.006 264.5);
--color-picker-bg: white;
--color-picker-border: oklch(0.899 0.013 261.1);
--color-picker-hover: oklch(0.964 0.007 264.5);
--color-picker-check: oklch(0.208 0.042 265.8);
}

[data-theme="dark"] {
--color-page-bg: oklch(0.178 0.014 265.8);
--color-page-gradient: oklch(0.305 0.058 41);
--color-heading: oklch(0.964 0.007 264.5);
--color-subheading: oklch(0.556 0.022 257.4);
--color-title: oklch(0.928 0.006 264.5);
--color-meta: oklch(0.5 0.02 261.1);
--color-meta-hover: oklch(0.65 0.02 257.4);
--color-badge-bg: oklch(0.27 0.02 261.1);
--color-badge-text: oklch(0.65 0.02 257.4);
--color-badge-hover-bg: oklch(0.38 0.06 50);
--color-badge-hover-text: oklch(0.899 0.061 71.3);
--color-card-hover: oklch(0.23 0.015 264.5);
--color-picker-bg: oklch(0.23 0.015 264.5);
--color-picker-border: oklch(0.32 0.02 261.1);
--color-picker-hover: oklch(0.28 0.018 264.5);
--color-picker-check: oklch(0.928 0.006 264.5);
}

[data-theme="forest"] {
--color-page-bg: oklch(0.97 0.01 145);
--color-page-gradient: oklch(0.85 0.08 148);
--color-heading: oklch(0.22 0.04 150);
--color-subheading: oklch(0.5 0.04 150);
--color-title: oklch(0.22 0.04 150);
--color-meta: oklch(0.58 0.04 150);
--color-meta-hover: oklch(0.42 0.04 150);
--color-badge-bg: oklch(0.88 0.04 148);
--color-badge-text: oklch(0.42 0.04 150);
--color-badge-hover-bg: oklch(0.82 0.09 148);
--color-badge-hover-text: oklch(0.22 0.04 150);
--color-card-hover: oklch(0.93 0.02 148);
--color-picker-bg: white;
--color-picker-border: oklch(0.88 0.04 148);
--color-picker-hover: oklch(0.94 0.02 148);
--color-picker-check: oklch(0.22 0.04 150);
}

[data-theme="ocean"] {
--color-page-bg: oklch(0.97 0.01 230);
--color-page-gradient: oklch(0.82 0.08 235);
--color-heading: oklch(0.22 0.04 240);
--color-subheading: oklch(0.5 0.04 240);
--color-title: oklch(0.22 0.04 240);
--color-meta: oklch(0.58 0.04 235);
--color-meta-hover: oklch(0.42 0.04 240);
--color-badge-bg: oklch(0.88 0.04 230);
--color-badge-text: oklch(0.42 0.04 240);
--color-badge-hover-bg: oklch(0.78 0.09 235);
--color-badge-hover-text: oklch(0.22 0.04 240);
--color-card-hover: oklch(0.93 0.02 230);
--color-picker-bg: white;
--color-picker-border: oklch(0.88 0.04 230);
--color-picker-hover: oklch(0.94 0.02 230);
--color-picker-check: oklch(0.22 0.04 240);
}

@theme inline {
--color-theme-page-bg: var(--color-page-bg);
--color-theme-page-gradient: var(--color-page-gradient);
--color-theme-heading: var(--color-heading);
--color-theme-subheading: var(--color-subheading);
--color-theme-title: var(--color-title);
--color-theme-meta: var(--color-meta);
--color-theme-meta-hover: var(--color-meta-hover);
--color-theme-badge-bg: var(--color-badge-bg);
--color-theme-badge-text: var(--color-badge-text);
--color-theme-badge-hover-bg: var(--color-badge-hover-bg);
--color-theme-badge-hover-text: var(--color-badge-hover-text);
--color-theme-card-hover: var(--color-card-hover);
--color-theme-picker-bg: var(--color-picker-bg);
--color-theme-picker-border: var(--color-picker-border);
--color-theme-picker-hover: var(--color-picker-hover);
--color-theme-picker-check: var(--color-picker-check);
}
5 changes: 4 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ThemeProvider } from './ThemeContext.tsx'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
)
16 changes: 16 additions & 0 deletions src/themeConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type Theme = 'default' | 'dark' | 'forest' | 'ocean'

export interface ThemeOption {
id: Theme
label: string
swatch: string
}

export const themes: ThemeOption[] = [
{ id: 'default', label: 'Default', swatch: '#f59e0b' },
{ id: 'dark', label: 'Dark', swatch: '#1e1e2e' },
{ id: 'forest', label: 'Forest', swatch: '#4ade80' },
{ id: 'ocean', label: 'Ocean', swatch: '#60a5fa' },
]

export const STORAGE_KEY = 'calmhn-theme'
9 changes: 9 additions & 0 deletions src/themes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createContext } from 'react'
import type { Theme } from './themeConfig'

export interface ThemeContextValue {
theme: Theme
setTheme: (theme: Theme) => void
}

export const ThemeContext = createContext<ThemeContextValue | null>(null)
8 changes: 8 additions & 0 deletions src/useTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useContext } from 'react'
import { ThemeContext } from './themes'

export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
return ctx
}
Loading