Skip to content

Commit 631353f

Browse files
committed
add plausible as feature, add image service/proxy as feature
1 parent f93de54 commit 631353f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+3169
-190
lines changed

packages/ssr-daisyui/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ NODE_ENV=development
22
INSTANCE_NAME=localhost
33
ORIGIN=http://localhost:5173
44
PLAUSIBLE_HOST=https://plausible.io
5+
API_URL=http://localhost:3000/api

packages/ssr-daisyui/Dockerfile

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
1-
FROM node:22-alpine AS base
2-
RUN corepack enable
1+
FROM node:24-alpine AS base
32

43
# Install dependencies
54
FROM base AS deps
65
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
76
RUN apk add --no-cache libc6-compat
8-
97
WORKDIR /app
108

119
COPY .yarnrc.yml ./
1210
COPY package.json yarn.lock ./
1311

14-
RUN yarn --version
15-
RUN yarn install --immutable
16-
12+
RUN corepack enable
13+
RUN yarn --immutable
1714

1815
# Build the application
1916
FROM base AS builder
@@ -28,6 +25,7 @@ COPY --from=deps /app/package.json ./package.json
2825
COPY --from=deps /app/yarn.lock ./yarn.lock
2926
COPY . .
3027

28+
RUN corepack enable
3129
RUN yarn build
3230
RUN yarn workspaces focus --production
3331
RUN yarn cache clean --all
@@ -55,4 +53,4 @@ HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=10 \
5553

5654
EXPOSE 3000
5755

58-
CMD ["npm", "run", "start"]
56+
CMD ["npm", "run", "start"]

packages/ssr-daisyui/app/components/theme/footer.tsx

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@ export const Footer: React.FC = () => {
1717
items: [
1818
{
1919
name: t('menu.home.name', 'Home'),
20-
path: (lang: string) => href('/:lang?/home', { lang }),
20+
path: (lang: string) => href('/:lang/home', { lang }),
2121
},
2222
{
2323
name: t('menu.form.name', 'Example Form'),
24-
path: (lang: string) => href('/:lang?/form', { lang }),
24+
path: (lang: string) => href('/:lang/form', { lang }),
25+
},
26+
{
27+
name: t('menu.images.name', 'Image Examples'),
28+
path: (lang: string) => href('/:lang/images', { lang }),
2529
},
2630
],
2731
section: t('menu.footer.section.products', 'Products'),
@@ -30,11 +34,15 @@ export const Footer: React.FC = () => {
3034
items: [
3135
{
3236
name: t('menu.home.name', 'Home'),
33-
path: (lang: string) => href('/:lang?/home', { lang }),
37+
path: (lang: string) => href('/:lang/home', { lang }),
3438
},
3539
{
3640
name: t('menu.form.name', 'Example Form'),
37-
path: (lang: string) => href('/:lang?/form', { lang }),
41+
path: (lang: string) => href('/:lang/form', { lang }),
42+
},
43+
{
44+
name: t('menu.images.name', 'Image Examples'),
45+
path: (lang: string) => href('/:lang/images', { lang }),
3846
},
3947
],
4048
section: t('menu.footer.section.company', 'Company'),
@@ -43,11 +51,15 @@ export const Footer: React.FC = () => {
4351
items: [
4452
{
4553
name: t('menu.home.name', 'Home'),
46-
path: (lang: string) => href('/:lang?/home', { lang }),
54+
path: (lang: string) => href('/:lang/home', { lang }),
4755
},
4856
{
4957
name: t('menu.form.name', 'Example Form'),
50-
path: (lang: string) => href('/:lang?/form', { lang }),
58+
path: (lang: string) => href('/:lang/form', { lang }),
59+
},
60+
{
61+
name: t('menu.images.name', 'Image Examples'),
62+
path: (lang: string) => href('/:lang/images', { lang }),
5163
},
5264
],
5365
section: t('menu.footer.section.resources', 'Resources'),
@@ -112,10 +124,10 @@ export const Footer: React.FC = () => {
112124
})}
113125
</div>
114126
<div className="flex flex-wrap gap-x-6 gap-y-2">
115-
<Link className="hover:underline" to={href('/:lang?/legal', { lang })}>
127+
<Link className="hover:underline" to={href('/:lang/legal', { lang })}>
116128
{t('theme.footer.legalLink', 'Legal information')}
117129
</Link>
118-
<Link className="hover:underline" to={href('/:lang?/privacy', { lang })}>
130+
<Link className="hover:underline" to={href('/:lang/privacy', { lang })}>
119131
{t('theme.footer.privacyLink', 'Privacy Policy')}
120132
</Link>
121133
</div>

packages/ssr-daisyui/app/components/theme/header/nav-drawer.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,15 @@ export const NavDrawer: React.FC = () => {
1919
const menu: MenuItem[] = [
2020
{
2121
name: t('menu.home.name', 'Home'),
22-
path: (lang: string) => href('/:lang?/home', { lang }),
22+
path: (lang: string) => href('/:lang/home', { lang }),
2323
},
2424
{
2525
name: t('menu.form.name', 'Example Form'),
26-
path: (lang: string) => href('/:lang?/form', { lang }),
26+
path: (lang: string) => href('/:lang/form', { lang }),
27+
},
28+
{
29+
name: t('menu.images.name', 'Image Examples'),
30+
path: (lang: string) => href('/:lang/images', { lang }),
2731
},
2832
]
2933

packages/ssr-daisyui/app/components/ui/language-switcher.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,24 @@ export const LanguageSwitcher: React.FC = () => {
1010
const { t } = useTranslation()
1111
const location = useLocation()
1212

13+
const getLocalizedPath = (targetLang: string) => {
14+
const segments = location.pathname.split('/')
15+
// Check if the first segment (after empty string from leading /) is a supported language
16+
if (segments[1] && i18nConfig.supportedLngs.includes(segments[1])) {
17+
// Replace the language segment
18+
segments[1] = targetLang
19+
return segments.join('/')
20+
}
21+
// If no language in path, assume we're at root and redirect to home with language
22+
return `/${targetLang}/home`
23+
}
24+
1325
return (
1426
<div className="flex gap-2" title={t('language.switcher.title', 'Change Language')}>
1527
{i18nConfig.supportedLngs
1628
.filter((each) => each !== lang)
1729
.map((each) => (
18-
<Link key={each} reloadDocument={true} to={location.pathname.replace(lang, each)}>
30+
<Link key={each} reloadDocument={true} to={getLocalizedPath(each)}>
1931
<Button size="xs" variant="ghost">
2032
{each.toUpperCase()}
2133
</Button>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Create optimized image URL for our API (Astro-compatible)
2+
3+
export function createOptimizedUrl(
4+
src: string,
5+
fit?: string,
6+
format?: string,
7+
height?: number,
8+
quality?: number,
9+
width?: number,
10+
): string {
11+
const searchParams = new URLSearchParams()
12+
13+
// Process the source URL (proxy if needed)
14+
const processedSrc = src
15+
searchParams.set('src', processedSrc)
16+
17+
// Add transformation parameters using Astro-compatible parameter names
18+
if (width) {
19+
searchParams.set('w', width.toString())
20+
}
21+
if (height) {
22+
searchParams.set('h', height.toString())
23+
}
24+
if (format) {
25+
searchParams.set('f', format)
26+
}
27+
if (quality) {
28+
searchParams.set('q', quality.toString())
29+
}
30+
if (fit) {
31+
searchParams.set('fit', fit)
32+
}
33+
34+
return `/api/image?${searchParams.toString()}`
35+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Generate srcset for responsive images (unpic-style)
2+
import { createOptimizedUrl } from './create-optimized-url.ts'
3+
4+
export function generateSrcSet(
5+
src: string,
6+
baseWidth: number,
7+
fit?: string,
8+
format?: string,
9+
height?: number,
10+
quality?: number,
11+
): string {
12+
// For container-based responsive images, generate a minimal srcset
13+
// Only include 1x and 2x variants of the exact target size
14+
const widths = [baseWidth]
15+
16+
// Add 2x variant for high-DPI displays, but cap at reasonable maximums
17+
const highDpiWidth = Math.min(baseWidth * 2, 2560)
18+
if (highDpiWidth > baseWidth) {
19+
widths.push(highDpiWidth)
20+
}
21+
22+
return widths
23+
.map((w) => {
24+
const aspectRatio = height && baseWidth ? height / baseWidth : undefined
25+
const scaledHeight = aspectRatio ? Math.round(w * aspectRatio) : undefined
26+
27+
const url = createOptimizedUrl(src, fit, format, scaledHeight, quality, w)
28+
return `${url} ${w}w`
29+
})
30+
.join(', ')
31+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { isNumber } from 'es-toolkit/compat'
2+
3+
import { type Media } from '~/types/media.ts'
4+
5+
export function getImageUrl(src: Media | null | number | string | undefined): null | string {
6+
// Handle null/undefined
7+
if (!src) return null
8+
9+
// Handle string paths
10+
if (typeof src === 'string') {
11+
return src
12+
}
13+
14+
// Handle media objects
15+
if (typeof src === 'object') {
16+
return src.url || src.filename || null
17+
}
18+
19+
// Handle ID numbers (can't resolve without API call)
20+
if (isNumber(src)) {
21+
console.warn('Image: Cannot render media ID without full media object')
22+
return null
23+
}
24+
25+
return null
26+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import React, { useEffect, useRef, useState } from 'react'
2+
3+
import { cn } from '~/lib/utils.ts'
4+
5+
import { type ImageProps } from '../../types'
6+
import { createOptimizedUrl } from './helper/create-optimized-url.ts'
7+
import { generateSrcSet } from './helper/generate-src-set.ts'
8+
import { getImageUrl } from './helper/get-image-url.ts'
9+
10+
export function Image({
11+
alt,
12+
className,
13+
disableSrcSet = false,
14+
fit,
15+
format = 'webp',
16+
height,
17+
priority = false,
18+
quality = 75,
19+
sizes,
20+
src,
21+
width,
22+
...imgProps
23+
}: ImageProps) {
24+
const [isLoaded, setIsLoaded] = useState(false)
25+
const imgRef = useRef<HTMLImageElement>(null)
26+
27+
// Extract URL from various src types
28+
const imageUrl = getImageUrl(src)
29+
30+
// Extract alt text from media object if available and alt prop not provided
31+
let altText = alt
32+
if (!alt && typeof src === 'object' && src && 'altText' in src) {
33+
altText = src.altText || ''
34+
}
35+
36+
// Use dimensions from media object if not specified
37+
const imageWidth =
38+
width ||
39+
(typeof src === 'object' && src && 'width' in src && typeof src.width === 'number'
40+
? src.width
41+
: undefined)
42+
const imageHeight =
43+
height ||
44+
(typeof src === 'object' && src && 'height' in src && typeof src.height === 'number'
45+
? src.height
46+
: undefined)
47+
48+
// Create the main optimized URL
49+
const optimizedSrc = imageUrl
50+
? createOptimizedUrl(imageUrl, fit, format, imageHeight, quality, imageWidth)
51+
: ''
52+
53+
// Generate responsive srcset if width is provided
54+
const srcSet =
55+
!disableSrcSet && imageWidth && imageUrl
56+
? generateSrcSet(imageUrl, imageWidth, fit, format, imageHeight, quality)
57+
: undefined
58+
59+
// Check if image is already loaded (e.g., from cache)
60+
useEffect(() => {
61+
if (imgRef.current && imgRef.current.complete && imgRef.current.naturalHeight > 0) {
62+
setIsLoaded(true)
63+
}
64+
}, [optimizedSrc])
65+
66+
if (!imageUrl) {
67+
return null
68+
}
69+
70+
return (
71+
<img
72+
{...imgProps}
73+
alt={altText}
74+
className={cn(
75+
'transition-opacity duration-300 ease-in-out',
76+
isLoaded ? 'opacity-100' : 'opacity-0',
77+
className,
78+
)}
79+
decoding="async"
80+
height={imageHeight}
81+
loading={priority ? 'eager' : 'lazy'}
82+
onLoad={(e) => {
83+
setIsLoaded(true)
84+
imgProps.onLoad?.(e)
85+
}}
86+
ref={imgRef}
87+
sizes={sizes}
88+
src={optimizedSrc}
89+
srcSet={srcSet}
90+
width={imageWidth}
91+
/>
92+
)
93+
}

0 commit comments

Comments
 (0)