feat: alert and button styles redesign

This commit is contained in:
Jake Turner 2025-11-30 23:32:16 -08:00
parent 12a6f2230d
commit f4a69ea401
No known key found for this signature in database
GPG Key ID: 694BC38EF2ED4844
6 changed files with 345 additions and 76 deletions

View File

@ -125,13 +125,15 @@ export class MapService {
throw new Error('Base styles file not found in storage/maps') throw new Error('Base styles file not found in storage/maps')
} }
const localUrl = env.get('URL')
const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile
const regions = (await this.listRegions()).files const regions = (await this.listRegions()).files
const sources = this.generateSourcesArray(regions) const sources = this.generateSourcesArray(regions)
const baseUrl = urlJoin(localUrl, this.mapStoragePath, this.basemapsAssetsDir) const localUrl = env.get('URL')
const withProtocol = localUrl.startsWith('http') ? localUrl : `http://${localUrl}`
const baseUrlPath = urlJoin(this.mapStoragePath, this.basemapsAssetsDir)
const baseUrl = new URL(baseUrlPath, withProtocol).toString()
const styles = await this.generateStylesFile( const styles = await this.generateStylesFile(
rawStyles, rawStyles,
@ -173,10 +175,15 @@ export class MapService {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) { if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
const regionName = region.name.replace('.pmtiles', '') const regionName = region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {} const source: BaseStylesFile['sources'] = {}
const sourceUrl = new URL(
urlJoin(this.mapStoragePath, 'pmtiles', region.name),
localUrl.startsWith('http') ? localUrl : `http://${localUrl}`
).toString()
source[regionName] = { source[regionName] = {
type: 'vector', type: 'vector',
attribution: PMTILES_ATTRIBUTION, attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://http://${urlJoin(localUrl, this.mapStoragePath, 'pmtiles', region.name)}`, url: `pmtiles://${sourceUrl}`,
} }
sources.push(source) sources.push(source)
} }

View File

@ -1,62 +1,200 @@
import { ExclamationTriangleIcon, XCircleIcon } from '@heroicons/react/24/solid' import * as Icons from '@heroicons/react/24/solid'
import { IconCircleCheck } from '@tabler/icons-react'
import classNames from '~/lib/classNames' import classNames from '~/lib/classNames'
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & { export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
title: string title: string
message?: string message?: string
type: 'warning' | 'error' | 'success' type: 'warning' | 'error' | 'success' | 'info'
children?: React.ReactNode children?: React.ReactNode
dismissible?: boolean
onDismiss?: () => void
icon?: keyof typeof Icons
variant?: 'standard' | 'bordered' | 'solid'
} }
export default function Alert({ title, message, type, children, ...props }: AlertProps) { export default function Alert({
const getIcon = () => { title,
const Icon = message,
type === 'warning' type,
? ExclamationTriangleIcon children,
: type === 'error' dismissible = false,
? XCircleIcon onDismiss,
: IconCircleCheck icon,
const color = variant = 'standard',
type === 'warning' ? 'text-yellow-400' : type === 'error' ? 'text-red-400' : 'text-green-400' ...props
}: AlertProps) {
return <Icon aria-hidden="true" className={`size-5 ${color}`} /> const getDefaultIcon = (): keyof typeof Icons => {
switch (type) {
case 'warning':
return 'ExclamationTriangleIcon'
case 'error':
return 'XCircleIcon'
case 'success':
return 'CheckCircleIcon'
case 'info':
return 'InformationCircleIcon'
default:
return 'InformationCircleIcon'
}
} }
const getBackground = () => { const IconComponent = () => {
return type === 'warning' ? 'bg-yellow-100' : type === 'error' ? 'bg-red-50' : 'bg-green-50' const iconName = icon || getDefaultIcon()
const Icon = Icons[iconName]
if (!Icon) return null
return <Icon aria-hidden="true" className={classNames('size-5 shrink-0', getIconColor())} />
} }
const getTextColor = () => { const getIconColor = () => {
return type === 'warning' if (variant === 'solid') return 'text-desert-white'
? 'text-yellow-800' switch (type) {
: type === 'error' case 'warning':
? 'text-red-800' return 'text-desert-orange'
: 'text-green-800' case 'error':
return 'text-desert-red'
case 'success':
return 'text-desert-olive'
case 'info':
return 'text-desert-stone'
default:
return 'text-desert-stone'
}
}
const getVariantStyles = () => {
const baseStyles = 'rounded-md transition-all duration-200'
const variantStyles: string[] = []
switch (variant) {
case 'bordered':
variantStyles.push(
type === 'warning'
? 'border-desert-orange'
: type === 'error'
? 'border-desert-red'
: type === 'success'
? 'border-desert-olive'
: type === 'info'
? 'border-desert-stone'
: ''
)
return classNames(baseStyles, 'border-2 bg-desert-white', ...variantStyles)
case 'solid':
variantStyles.push(
type === 'warning'
? 'bg-desert-orange text-desert-white border-desert-orange-dark'
: type === 'error'
? 'bg-desert-red text-desert-white border-desert-red-dark'
: type === 'success'
? 'bg-desert-olive text-desert-white border-desert-olive-dark'
: type === 'info'
? 'bg-desert-stone text-desert-white border-desert-stone-dark'
: ''
)
return classNames(baseStyles, 'shadow-sm', ...variantStyles)
default:
variantStyles.push(
type === 'warning'
? 'bg-desert-orange-lighter bg-opacity-20 border-desert-orange-light'
: type === 'error'
? 'bg-desert-red-lighter bg-opacity-20 border-desert-red-light'
: type === 'success'
? 'bg-desert-olive-lighter bg-opacity-20 border-desert-olive-light'
: type === 'info'
? 'bg-desert-stone-lighter bg-opacity-20 border-desert-stone-light'
: ''
)
return classNames(baseStyles, 'border shadow-sm', ...variantStyles)
}
}
const getTitleColor = () => {
if (variant === 'solid') return 'text-desert-white'
switch (type) {
case 'warning':
return 'text-desert-orange-dark'
case 'error':
return 'text-desert-red-dark'
case 'success':
return 'text-desert-olive-dark'
case 'info':
return 'text-desert-stone-dark'
default:
return 'text-desert-stone-dark'
}
}
const getMessageColor = () => {
if (variant === 'solid') return 'text-desert-white text-opacity-90'
switch (type) {
case 'warning':
return 'text-desert-orange-dark text-opacity-80'
case 'error':
return 'text-desert-red-dark text-opacity-80'
case 'success':
return 'text-desert-olive-dark text-opacity-80'
case 'info':
return 'text-desert-stone-dark text-opacity-80'
default:
return 'text-desert-stone-dark text-opacity-80'
}
}
const getCloseButtonStyles = () => {
if (variant === 'solid') {
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20'
}
switch (type) {
case 'warning':
return 'text-desert-orange hover:text-desert-orange-dark hover:bg-desert-orange-lighter hover:bg-opacity-30'
case 'error':
return 'text-desert-red hover:text-desert-red-dark hover:bg-desert-red-lighter hover:bg-opacity-30'
case 'success':
return 'text-desert-olive hover:text-desert-olive-dark hover:bg-desert-olive-lighter hover:bg-opacity-30'
case 'info':
return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'
default:
return 'text-desert-stone hover:text-desert-stone-dark hover:bg-desert-stone-lighter hover:bg-opacity-30'
}
} }
return ( return (
<div <div {...props} className={classNames(getVariantStyles(), 'p-4', props.className)} role="alert">
{...props} <div className="flex gap-3">
className={classNames( <IconComponent />
getBackground(),
props.className, <div className="flex-1 min-w-0">
'border border-gray-200 rounded-md p-3 shadow-xs' <h3 className={classNames('text-sm font-semibold', getTitleColor())}>{title}</h3>
)} {message && (
> <div className={classNames('mt-1 text-sm', getMessageColor())}>
<div className="flex flex-row justify-between items-center"> <p>{message}</p>
<div className="flex flex-row"> </div>
<div className="shrink-0">{getIcon()}</div> )}
<div className="ml-3"> {children && <div className="mt-3">{children}</div>}
<h3 className={`text-sm font-medium ${getTextColor()}`}>{title}</h3>
{message && (
<div className={`mt-2 text-sm ${getTextColor()}`}>
<p>{message}</p>
</div>
)}
</div>
</div> </div>
{children}
{dismissible && (
<button
type="button"
onClick={onDismiss}
className={classNames(
'shrink-0 rounded-md p-1.5 transition-colors duration-150',
getCloseButtonStyles(),
'focus:outline-none focus:ring-2 focus:ring-offset-2',
type === 'warning' ? 'focus:ring-desert-orange' : '',
type === 'error' ? 'focus:ring-desert-red' : '',
type === 'success' ? 'focus:ring-desert-olive' : '',
type === 'info' ? 'focus:ring-desert-stone' : ''
)}
aria-label="Dismiss alert"
>
<Icons.XMarkIcon className="size-5" />
</button>
)}
</div> </div>
</div> </div>
) )

View File

@ -6,15 +6,19 @@ export interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElemen
// icon should be one of the HeroIcon names, e.g. ArrowTopRightOnSquareIcon // icon should be one of the HeroIcon names, e.g. ArrowTopRightOnSquareIcon
icon?: keyof typeof Icons icon?: keyof typeof Icons
disabled?: boolean disabled?: boolean
variant?: 'primary' | 'secondary' | 'danger' | 'action' variant?: 'primary' | 'secondary' | 'danger' | 'action' | 'success' | 'ghost' | 'outline'
size?: 'sm' | 'md' | 'lg'
loading?: boolean loading?: boolean
fullWidth?: boolean
} }
const StyledButton: React.FC<StyledButtonProps> = ({ const StyledButton: React.FC<StyledButtonProps> = ({
children, children,
icon, icon,
variant = 'primary', variant = 'primary',
size = 'md',
loading = false, loading = false,
fullWidth = false,
...props ...props
}) => { }) => {
const isDisabled = useMemo(() => { const isDisabled = useMemo(() => {
@ -24,30 +28,113 @@ const StyledButton: React.FC<StyledButtonProps> = ({
const IconComponent = () => { const IconComponent = () => {
if (!icon) return null if (!icon) return null
const Icon = Icons[icon] const Icon = Icons[icon]
return Icon ? <Icon className="h-4 w-4 mr-2" /> : null return Icon ? <Icon className={getIconSize()} /> : null
} }
const getBgColors = () => { const getIconSize = () => {
// if primary, use desert-green switch (size) {
if (variant === 'primary') { case 'sm':
return 'bg-desert-green hover:bg-desert-green-light text-white hover:shadow-lg transition-all duration-200' return 'h-3.5 w-3.5 mr-1.5'
} case 'lg':
// if secondary, use outlined styles return 'h-5 w-5 mr-2.5'
if (variant === 'secondary') { default:
return 'bg-transparent border border-desert-green text-desert-green hover:bg-desert-green-light' return 'h-4 w-4 mr-2'
} }
}
// if danger, use red styles const getSizeClasses = () => {
if (variant === 'danger') { switch (size) {
return 'bg-red-600 hover:bg-red-700 text-white' case 'sm':
return 'px-2.5 py-1.5 text-xs'
case 'lg':
return 'px-4 py-3 text-base'
default:
return 'px-3 py-2 text-sm'
} }
}
// if action, use orange styles const getVariantClasses = () => {
if (variant === 'action') { const baseTransition = 'transition-all duration-200 ease-in-out'
return 'bg-desert-orange hover:bg-desert-orange-light text-white' const baseHover = 'hover:shadow-md active:scale-[0.98]'
switch (variant) {
case 'primary':
return `
bg-desert-green text-desert-white
hover:bg-desert-green-dark hover:shadow-lg
active:bg-desert-green-darker
disabled:bg-desert-green-light disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
case 'secondary':
return `
bg-desert-tan text-desert-white
hover:bg-desert-tan-dark hover:shadow-lg
active:bg-desert-tan-dark
disabled:bg-desert-tan-lighter disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
case 'danger':
return `
bg-desert-red text-desert-white
hover:bg-desert-red-dark hover:shadow-lg
active:bg-desert-red-dark
disabled:bg-desert-red-lighter disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
case 'action':
return `
bg-desert-orange text-desert-white
hover:bg-desert-orange-light hover:shadow-lg
active:bg-desert-orange-dark
disabled:bg-desert-orange-lighter disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
case 'success':
return `
bg-desert-olive text-desert-white
hover:bg-desert-olive-dark hover:shadow-lg
active:bg-desert-olive-dark
disabled:bg-desert-olive-lighter disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
case 'ghost':
return `
bg-transparent text-desert-green
hover:bg-desert-sand hover:text-desert-green-dark
active:bg-desert-green-lighter
disabled:text-desert-stone-light
${baseTransition}
`
case 'outline':
return `
bg-transparent border-2 border-desert-green text-desert-green
hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark
active:bg-desert-green-dark active:border-desert-green-darker
disabled:border-desert-green-lighter disabled:text-desert-stone-light
${baseTransition} ${baseHover}
`
default:
return ''
} }
} }
const getLoadingSpinner = () => {
const spinnerSize = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4'
return (
<Icons.ArrowPathIcon
className={`${spinnerSize} animate-spin ${fullWidth ? 'mx-auto' : ''}`}
/>
)
}
const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => { const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isDisabled) { if (isDisabled) {
e.preventDefault() e.preventDefault()
@ -59,23 +146,30 @@ const StyledButton: React.FC<StyledButtonProps> = ({
return ( return (
<button <button
type="button" type="button"
className={`block rounded-md ${getBgColors()} px-3 py-2 text-center text-sm font-semibold shadow-sm cursor-pointer disabled:opacity-50 disabled:pointer-events-none`} className={`
${fullWidth ? 'w-full' : 'inline-flex'}
items-center justify-center
rounded-md font-semibold
${getSizeClasses()}
${getVariantClasses()}
focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand
disabled:cursor-not-allowed disabled:shadow-none
${isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer'}
`}
{...props} {...props}
disabled={isDisabled} disabled={isDisabled}
onClick={onClickHandler} onClick={onClickHandler}
> >
{loading ? ( {loading ? (
<Icons.EllipsisHorizontalCircleIcon className="h-5 w-5 animate-spin text-white" /> getLoadingSpinner()
) : icon ? (
<div className="flex flex-row items-center justify-center">
<IconComponent />
{children}
</div>
) : ( ) : (
children <>
{icon && <IconComponent />}
<span className={fullWidth ? 'block text-center' : ''}>{children}</span>
</>
)} )}
</button> </button>
) )
} }
export default StyledButton export default StyledButton

View File

@ -2,13 +2,40 @@
@theme { @theme {
--color-desert-white: #f6f6f4; --color-desert-white: #f6f6f4;
--color-desert-green-light: #babaaa;
--color-desert-green: #424420;
--color-desert-orange: #a84a12;
--color-desert-sand: #f7eedc; --color-desert-sand: #f7eedc;
/* --color-desert-sand: #E2DAC2; */
--color-desert-green-darker: #2a2a15;
--color-desert-green-dark: #353518;
--color-desert-green: #424420;
--color-desert-green-light: #babaaa;
--color-desert-green-lighter: #d4d4c8;
--color-desert-orange-dark: #8a3d0f;
--color-desert-orange: #a84a12;
--color-desert-orange-light: #c85815;
--color-desert-orange-lighter: #e69556;
--color-desert-tan-dark: #6b5d4f;
--color-desert-tan: #8b7355;
--color-desert-tan-light: #a8927a;
--color-desert-tan-lighter: #c9b99f;
--color-desert-red-dark: #7a2e2e;
--color-desert-red: #994444;
--color-desert-red-light: #b05555;
--color-desert-red-lighter: #d88989;
--color-desert-olive-dark: #5a5c3a;
--color-desert-olive: #6d7042;
--color-desert-olive-light: #858a55;
--color-desert-olive-lighter: #a5ab7d;
--color-desert-stone-dark: #5c5c54;
--color-desert-stone: #75756a;
--color-desert-stone-light: #8f8f82;
--color-desert-stone-lighter: #afafa5;
} }
body { body {
background-color: var(--color-desert-sand); background-color: var(--color-desert-sand);
} }

View File

@ -70,6 +70,7 @@ export default function ZimPage() {
<Alert <Alert
title="The Kiwix application is not installed. Please install it to view downloaded ZIM files" title="The Kiwix application is not installed. Please install it to view downloaded ZIM files"
type="warning" type="warning"
variant='solid'
className="!mt-6" className="!mt-6"
/> />
)} )}

View File

@ -154,6 +154,7 @@ export default function ZimRemoteExplorer() {
title="No internet connection. You may not be able to download files." title="No internet connection. You may not be able to download files."
message="" message=""
type="warning" type="warning"
variant="solid"
className="!mt-6" className="!mt-6"
/> />
)} )}
@ -161,6 +162,7 @@ export default function ZimRemoteExplorer() {
<Alert <Alert
title="The Kiwix application is not installed. Please install it to view downloaded ZIM files" title="The Kiwix application is not installed. Please install it to view downloaded ZIM files"
type="warning" type="warning"
variant="solid"
className="!mt-6" className="!mt-6"
/> />
)} )}