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')
}
const localUrl = env.get('URL')
const rawStyles = JSON.parse(baseStyle.toString()) as BaseStylesFile
const regions = (await this.listRegions()).files
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(
rawStyles,
@ -173,10 +175,15 @@ export class MapService {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
const regionName = region.name.replace('.pmtiles', '')
const source: BaseStylesFile['sources'] = {}
const sourceUrl = new URL(
urlJoin(this.mapStoragePath, 'pmtiles', region.name),
localUrl.startsWith('http') ? localUrl : `http://${localUrl}`
).toString()
source[regionName] = {
type: 'vector',
attribution: PMTILES_ATTRIBUTION,
url: `pmtiles://http://${urlJoin(localUrl, this.mapStoragePath, 'pmtiles', region.name)}`,
url: `pmtiles://${sourceUrl}`,
}
sources.push(source)
}

View File

@ -1,62 +1,200 @@
import { ExclamationTriangleIcon, XCircleIcon } from '@heroicons/react/24/solid'
import { IconCircleCheck } from '@tabler/icons-react'
import * as Icons from '@heroicons/react/24/solid'
import classNames from '~/lib/classNames'
export type AlertProps = React.HTMLAttributes<HTMLDivElement> & {
title: string
message?: string
type: 'warning' | 'error' | 'success'
type: 'warning' | 'error' | 'success' | 'info'
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) {
const getIcon = () => {
const Icon =
type === 'warning'
? ExclamationTriangleIcon
: type === 'error'
? XCircleIcon
: IconCircleCheck
const color =
type === 'warning' ? 'text-yellow-400' : type === 'error' ? 'text-red-400' : 'text-green-400'
return <Icon aria-hidden="true" className={`size-5 ${color}`} />
export default function Alert({
title,
message,
type,
children,
dismissible = false,
onDismiss,
icon,
variant = 'standard',
...props
}: AlertProps) {
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 = () => {
return type === 'warning' ? 'bg-yellow-100' : type === 'error' ? 'bg-red-50' : 'bg-green-50'
const IconComponent = () => {
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 = () => {
return type === 'warning'
? 'text-yellow-800'
: type === 'error'
? 'text-red-800'
: 'text-green-800'
const getIconColor = () => {
if (variant === 'solid') return 'text-desert-white'
switch (type) {
case 'warning':
return 'text-desert-orange'
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 (
<div
{...props}
className={classNames(
getBackground(),
props.className,
'border border-gray-200 rounded-md p-3 shadow-xs'
)}
>
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row">
<div className="shrink-0">{getIcon()}</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${getTextColor()}`}>{title}</h3>
{message && (
<div className={`mt-2 text-sm ${getTextColor()}`}>
<p>{message}</p>
</div>
)}
</div>
<div {...props} className={classNames(getVariantStyles(), 'p-4', props.className)} role="alert">
<div className="flex gap-3">
<IconComponent />
<div className="flex-1 min-w-0">
<h3 className={classNames('text-sm font-semibold', getTitleColor())}>{title}</h3>
{message && (
<div className={classNames('mt-1 text-sm', getMessageColor())}>
<p>{message}</p>
</div>
)}
{children && <div className="mt-3">{children}</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>
)

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?: keyof typeof Icons
disabled?: boolean
variant?: 'primary' | 'secondary' | 'danger' | 'action'
variant?: 'primary' | 'secondary' | 'danger' | 'action' | 'success' | 'ghost' | 'outline'
size?: 'sm' | 'md' | 'lg'
loading?: boolean
fullWidth?: boolean
}
const StyledButton: React.FC<StyledButtonProps> = ({
children,
icon,
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
...props
}) => {
const isDisabled = useMemo(() => {
@ -24,30 +28,113 @@ const StyledButton: React.FC<StyledButtonProps> = ({
const IconComponent = () => {
if (!icon) return null
const Icon = Icons[icon]
return Icon ? <Icon className="h-4 w-4 mr-2" /> : null
return Icon ? <Icon className={getIconSize()} /> : null
}
const getBgColors = () => {
// if primary, use desert-green
if (variant === 'primary') {
return 'bg-desert-green hover:bg-desert-green-light text-white hover:shadow-lg transition-all duration-200'
}
// if secondary, use outlined styles
if (variant === 'secondary') {
return 'bg-transparent border border-desert-green text-desert-green hover:bg-desert-green-light'
const getIconSize = () => {
switch (size) {
case 'sm':
return 'h-3.5 w-3.5 mr-1.5'
case 'lg':
return 'h-5 w-5 mr-2.5'
default:
return 'h-4 w-4 mr-2'
}
}
// if danger, use red styles
if (variant === 'danger') {
return 'bg-red-600 hover:bg-red-700 text-white'
const getSizeClasses = () => {
switch (size) {
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
if (variant === 'action') {
return 'bg-desert-orange hover:bg-desert-orange-light text-white'
const getVariantClasses = () => {
const baseTransition = 'transition-all duration-200 ease-in-out'
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>) => {
if (isDisabled) {
e.preventDefault()
@ -59,23 +146,30 @@ const StyledButton: React.FC<StyledButtonProps> = ({
return (
<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}
disabled={isDisabled}
onClick={onClickHandler}
>
{loading ? (
<Icons.EllipsisHorizontalCircleIcon className="h-5 w-5 animate-spin text-white" />
) : icon ? (
<div className="flex flex-row items-center justify-center">
<IconComponent />
{children}
</div>
getLoadingSpinner()
) : (
children
<>
{icon && <IconComponent />}
<span className={fullWidth ? 'block text-center' : ''}>{children}</span>
</>
)}
</button>
)
}
export default StyledButton
export default StyledButton

View File

@ -2,13 +2,40 @@
@theme {
--color-desert-white: #f6f6f4;
--color-desert-green-light: #babaaa;
--color-desert-green: #424420;
--color-desert-orange: #a84a12;
--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 {
background-color: var(--color-desert-sand);
}
}

View File

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