Files
wbin90620 51bc31fd9d oss: the star-all spinner turns on data-spin, not a Tailwind class
BulkStarModal's loader carried `animate-spin`, which nothing defines since
Tailwind left the site, so it never turned, and the tailwind floor counts it
(0 -> 1) and fails CI/CD. `data-spin` is the site's own spinner (app/layout,
reduced motion respected), as components/home/Products uses.
2026-10-26 13:14:53 -04:00

155 lines
6.3 KiB
TypeScript

'use client'
import { useState } from 'react'
import { Check, Loader2, Star, X } from 'lucide-react'
import { Text, View, XStack, YStack } from '@hanzo/ui'
import { Action } from '@hanzo/ui/marketing'
import { useI18n } from '@hanzo/i18n/react'
export interface BulkStarModalProps {
open: boolean
onClose: () => void
repositories: readonly { repo: string; line: string }[]
}
/**
* Star the featured repositories. The forge stars through its own API for the
* signed-in reader, and each answer is the forge's: a repository it refused is
* not counted. GitHub offers no star without the reader's own press, so each
* repository is a link there, and the modal says how many the forge took and
* leaves GitHub to the reader.
*/
export default function BulkStarModal({ open, onClose, repositories }: BulkStarModalProps) {
const { t } = useI18n()
const [status, setStatus] = useState<'idle' | 'running' | 'done'>('idle')
const [progress, setProgress] = useState(0)
const [taken, setTaken] = useState<Record<string, boolean>>({})
if (!open) return null
const starAll = async () => {
setStatus('running')
setProgress(0)
const got: Record<string, boolean> = {}
for (const [i, { repo }] of repositories.entries()) {
try {
const res = await fetch(`https://forge.hanzo.ai/api/v1/user/starred/${repo}`, { method: 'PUT', credentials: 'include' })
got[repo] = res.ok
} catch {
got[repo] = false
}
setProgress(i + 1)
setTaken({ ...got })
}
setStatus('done')
}
const done = Object.values(taken).filter(Boolean).length
return (
<View
position="fixed"
t={0}
b={0}
l={0}
r={0}
items="center"
justify="center"
p={16}
$platform-web={{ backgroundColor: 'color-mix(in srgb, var(--background) 75%, transparent)', backdropFilter: 'blur(8px)', zIndex: 'var(--z-modal)' }}
onClick={onClose}
>
<YStack
role="dialog"
aria-modal
aria-label={t('Star the featured repositories')}
width="100%"
maxW={520}
p={24}
rounded="var(--radius-xl)"
borderWidth={1}
borderColor="$edge"
bg="$panel"
gap={16}
$platform-web={{ boxShadow: 'var(--shadow-floating)' }}
onClick={(e) => e.stopPropagation()}
>
<XStack items="center" justify="space-between">
<XStack items="center" gap={8}>
<Star size={18} color="var(--foreground)" aria-hidden />
<Text render="h2" fontSize="$4" fontWeight="600" color="var(--foreground)">
{t('Star the featured repositories')}
</Text>
</XStack>
<XStack render={<button type="button" onClick={onClose} aria-label={t('Close')} />} p={4} rounded={6} cursor="pointer" hoverStyle={{ bg: 'var(--secondary)' }} $platform-web={{ border: 'none', background: 'transparent' }}>
<X size={18} color="var(--muted-foreground)" aria-hidden />
</XStack>
</XStack>
<Text fontSize="$2" lineHeight="$3" color="var(--muted-foreground)">
{t('Starring on Hanzo Forge takes one press. GitHub takes yours on each repository: every row opens it.')}
</Text>
<YStack maxH={240} gap={2} p={10} rounded={8} borderWidth={1} borderColor="var(--border)" $platform-web={{ borderRadius: '8px', overflowY: 'auto' }}>
{repositories.map(({ repo }) => (
<XStack key={repo} items="center" justify="space-between" gap={8} py={4} px={6}>
<Text render={<a href={`https://github.com/${repo}`} target="_blank" rel="noopener noreferrer" />} fontFamily="$mono" fontSize="$1" color="var(--foreground)" $platform-web={{ textDecoration: 'none' }}>
{repo}
</Text>
{status === 'running' && !(repo in taken) ? (
<Text fontSize="$1" color="var(--muted-foreground)">
{t('pending')}
</Text>
) : repo in taken ? (
<XStack items="center" gap={4}>
{taken[repo] ? <Check size={12} color="var(--foreground)" aria-hidden /> : null}
<Text fontSize="$1" color="var(--muted-foreground)">
{taken[repo] ? t('starred on the forge') : t('not starred, sign in to the forge')}
</Text>
</XStack>
) : null}
</XStack>
))}
</YStack>
{status === 'running' ? (
<YStack gap={6}>
<XStack items="center" justify="space-between">
<Text fontSize="$1" color="var(--muted-foreground)">
{t('Starring on the forge…')}
</Text>
<Text fontFamily="$mono" fontSize="$1" color="var(--foreground)">
{progress} / {repositories.length}
</Text>
</XStack>
<View width="100%" height={4} rounded={2} bg="var(--secondary)" $platform-web={{ borderRadius: '2px', overflow: 'hidden' }}>
<View height={4} bg="var(--foreground)" $platform-web={{ width: `${(progress / repositories.length) * 100}%`, transition: 'width 0.15s ease-out' }} />
</View>
</YStack>
) : null}
{status === 'done' ? (
<XStack items="center" gap={8} p={10} rounded={8} borderWidth={1} borderColor="var(--border)" $platform-web={{ borderRadius: '8px' }} role="status">
<Check size={16} color="var(--foreground)" aria-hidden />
<Text fontSize="$2" fontWeight="600" color="var(--foreground)">
{t('{done} of {all} starred on the forge.', { done, all: repositories.length })}
</Text>
</XStack>
) : null}
<XStack gap={10} justify="flex-end" pt={8}>
<Action render="button" type="button" onClick={onClose}>
{status === 'done' ? t('Done') : t('Cancel')}
</Action>
{status !== 'done' ? (
<Action render="button" type="button" fill onClick={() => void starAll()} disabled={status === 'running'}>
{status === 'running' ? <Loader2 size={14} aria-hidden data-spin="" /> : <Star size={14} aria-hidden />}
{status === 'running' ? t('Starring…') : t('Star on the forge')}
</Action>
) : null}
</XStack>
</YStack>
</View>
)
}