handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

refactor: clean up ui components

mary.my.id d444f7eb b92f92d2

verified
Changed files
+158 -157
src
components
views
blob
bluesky
threadgate-applicator
crypto
identity
repository
+65
src/components/file-drop-zone.tsx
···
··· 1 + import type { JSX } from 'solid-js'; 2 + 3 + import { createDropZone, type CreateDropZoneOptions } from '~/lib/hooks/dropzone'; 4 + 5 + import Button from './inputs/button'; 6 + 7 + interface FileDropZoneProps { 8 + accept?: string; 9 + disabled?: boolean; 10 + onFiles: (files: File[]) => void; 11 + dataTypes?: CreateDropZoneOptions['dataTypes']; 12 + multiple?: boolean; 13 + children?: JSX.Element; 14 + } 15 + 16 + const FileDropZone = (props: FileDropZoneProps) => { 17 + const { ref: dropRef, isDropping } = createDropZone({ 18 + dataTypes: props.dataTypes, 19 + multiple: props.multiple ?? false, 20 + onDrop(files) { 21 + if (files) { 22 + props.onFiles(files); 23 + } 24 + }, 25 + }); 26 + 27 + const handleBrowse = () => { 28 + const input = document.createElement('input'); 29 + input.type = 'file'; 30 + if (props.accept) { 31 + input.accept = props.accept; 32 + } 33 + if (props.multiple) { 34 + input.multiple = true; 35 + } 36 + input.oninput = () => { 37 + const files = Array.from(input.files!); 38 + if (files.length > 0) { 39 + props.onFiles(files); 40 + } 41 + }; 42 + input.click(); 43 + }; 44 + 45 + return ( 46 + <fieldset 47 + ref={dropRef} 48 + disabled={props.disabled} 49 + class={ 50 + `relative grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` + 51 + (props.disabled || !isDropping() ? ` bg-gray-100` : ` bg-green-100`) 52 + } 53 + > 54 + <div class="flex flex-col items-center gap-4"> 55 + <Button variant="outline" onClick={handleBrowse}> 56 + Browse files 57 + </Button> 58 + <p class="select-none font-medium text-gray-600">or drop your file here</p> 59 + </div> 60 + {props.children} 61 + </fieldset> 62 + ); 63 + }; 64 + 65 + export default FileDropZone;
+27 -12
src/components/inputs/button.tsx
··· 1 - import type { JSX } from 'solid-js'; 2 3 interface ButtonProps { 4 children?: JSX.Element; 5 disabled?: boolean; 6 - variant?: 'primary' | 'secondary'; 7 type?: 'button' | 'submit'; 8 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 9 } 10 ··· 15 cn += ` bg-purple-800 text-white hover:bg-purple-700 active:bg-purple-700`; 16 } else if (variant === 'secondary') { 17 cn += ` bg-gray-200 text-black hover:bg-gray-300 active:bg-gray-300`; 18 } 19 20 if (disabled) { ··· 25 }; 26 27 const Button = (props: ButtonProps) => { 28 - return ( 29 - <button 30 - type={props.type ?? 'button'} 31 - disabled={props.disabled} 32 - class={buttonStyles(props)} 33 - onClick={props.onClick} 34 - > 35 - {props.children} 36 - </button> 37 - ); 38 }; 39 40 export default Button;
··· 1 + import { createMemo, type JSX } from 'solid-js'; 2 3 interface ButtonProps { 4 children?: JSX.Element; 5 disabled?: boolean; 6 + variant?: 'primary' | 'secondary' | 'outline'; 7 type?: 'button' | 'submit'; 8 + href?: string; 9 onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>; 10 } 11 ··· 16 cn += ` bg-purple-800 text-white hover:bg-purple-700 active:bg-purple-700`; 17 } else if (variant === 'secondary') { 18 cn += ` bg-gray-200 text-black hover:bg-gray-300 active:bg-gray-300`; 19 + } else if (variant === 'outline') { 20 + cn += ` border border-gray-300 text-gray-800 hover:bg-gray-100 active:bg-gray-100`; 21 } 22 23 if (disabled) { ··· 28 }; 29 30 const Button = (props: ButtonProps) => { 31 + const hasLink = createMemo(() => props.href !== undefined); 32 + 33 + return (() => { 34 + if (hasLink()) { 35 + return ( 36 + <a href={!props.disabled ? props.href : undefined} class={buttonStyles(props)}> 37 + {props.children} 38 + </a> 39 + ); 40 + } 41 + 42 + return ( 43 + <button 44 + type={props.type ?? 'button'} 45 + disabled={props.disabled} 46 + class={buttonStyles(props)} 47 + onClick={props.onClick} 48 + > 49 + {props.children} 50 + </button> 51 + ); 52 + }) as unknown as JSX.Element; 53 }; 54 55 export default Button;
+22
src/components/page-header.tsx
···
··· 1 + import type { JSX } from 'solid-js'; 2 + 3 + interface PageHeaderProps { 4 + title: string; 5 + subtitle?: string; 6 + children?: JSX.Element; 7 + } 8 + 9 + const PageHeader = (props: PageHeaderProps) => { 10 + return ( 11 + <> 12 + <div class="p-4"> 13 + <h1 class="text-lg font-bold text-purple-800">{props.title}</h1> 14 + {props.subtitle && <p class="text-gray-600">{props.subtitle}</p>} 15 + {props.children} 16 + </div> 17 + <hr class="mx-4 border-gray-300" /> 18 + </> 19 + ); 20 + }; 21 + 22 + export default PageHeader;
+2 -5
src/views/blob/blob-export.tsx
··· 17 import Button from '~/components/inputs/button'; 18 import TextInput from '~/components/inputs/text-input'; 19 import Logger, { createLogger } from '~/components/logger'; 20 21 const BlobExportPage = () => { 22 const logger = createLogger(); ··· 229 230 return ( 231 <> 232 - <div class="p-4"> 233 - <h1 class="text-lg font-bold text-purple-800">Export blobs</h1> 234 - <p class="text-gray-600">Download all blobs from an account into a tarball</p> 235 - </div> 236 - <hr class="mx-4 border-gray-300" /> 237 238 <form 239 onSubmit={(ev) => {
··· 17 import Button from '~/components/inputs/button'; 18 import TextInput from '~/components/inputs/text-input'; 19 import Logger, { createLogger } from '~/components/logger'; 20 + import PageHeader from '~/components/page-header'; 21 22 const BlobExportPage = () => { 23 const logger = createLogger(); ··· 230 231 return ( 232 <> 233 + <PageHeader title="Export blobs" subtitle="Download all blobs from an account into a tarball" /> 234 235 <form 236 onSubmit={(ev) => {
+2 -5
src/views/bluesky/threadgate-applicator/page.tsx
··· 10 11 import { useTitle } from '~/lib/navigation/router'; 12 13 import { Wizard } from '~/components/wizard'; 14 15 import Step1_HandleInput from './steps/step1_handle-input'; ··· 80 81 return ( 82 <> 83 - <div class="p-4"> 84 - <h1 class="text-lg font-bold text-purple-800">Retroactive thread gating</h1> 85 - <p class="text-gray-600">Set reply permissions on all of your past Bluesky posts</p> 86 - </div> 87 - <hr class="mx-4 border-gray-300" /> 88 89 <Wizard<ThreadgateApplicatorConstraints> 90 initialStep="Step1_HandleInput"
··· 10 11 import { useTitle } from '~/lib/navigation/router'; 12 13 + import PageHeader from '~/components/page-header'; 14 import { Wizard } from '~/components/wizard'; 15 16 import Step1_HandleInput from './steps/step1_handle-input'; ··· 81 82 return ( 83 <> 84 + <PageHeader title="Retroactive thread gating" subtitle="Set reply permissions on all of your past Bluesky posts" /> 85 86 <Wizard<ThreadgateApplicatorConstraints> 87 initialStep="Step1_HandleInput"
+2 -5
src/views/crypto/crypto-generate.tsx
··· 6 7 import Button from '~/components/inputs/button'; 8 import RadioInput from '~/components/inputs/radio-input'; 9 10 type KeyType = 'p256' | 'secp256k1'; 11 ··· 26 27 return ( 28 <> 29 - <div class="p-4"> 30 - <h1 class="text-lg font-bold text-purple-800">Generate secret keys</h1> 31 - <p class="text-gray-600">Create a new secp256k1/nistp256 keypair</p> 32 - </div> 33 - <hr class="mx-4 border-gray-300" /> 34 35 <form 36 onSubmit={async (ev) => {
··· 6 7 import Button from '~/components/inputs/button'; 8 import RadioInput from '~/components/inputs/radio-input'; 9 + import PageHeader from '~/components/page-header'; 10 11 type KeyType = 'p256' | 'secp256k1'; 12 ··· 27 28 return ( 29 <> 30 + <PageHeader title="Generate secret keys" subtitle="Create a new secp256k1/nistp256 keypair" /> 31 32 <form 33 onSubmit={async (ev) => {
+3 -5
src/views/frontpage.tsx
··· 2 3 import { useTitle } from '~/lib/navigation/router'; 4 5 import HistoryIcon from '~/components/ic-icons/baseline-history'; 6 import KeyIcon from '~/components/ic-icons/baseline-key'; 7 import KeyVisualizerIcon from '~/components/ic-icons/baseline-key-visualizer'; ··· 170 171 return ( 172 <> 173 - <div class="p-4"> 174 - <h1 class="text-lg font-bold text-purple-800">boat</h1> 175 - <p class="text-gray-600">handy online tools for AT Protocol</p> 176 - </div> 177 - <hr class="mx-4 border-gray-300" /> 178 179 <div class="flex grow flex-col pb-2">{nodes}</div> 180
··· 2 3 import { useTitle } from '~/lib/navigation/router'; 4 5 + import PageHeader from '~/components/page-header'; 6 + 7 import HistoryIcon from '~/components/ic-icons/baseline-history'; 8 import KeyIcon from '~/components/ic-icons/baseline-key'; 9 import KeyVisualizerIcon from '~/components/ic-icons/baseline-key-visualizer'; ··· 172 173 return ( 174 <> 175 + <PageHeader title="boat" subtitle="handy online tools for AT Protocol" /> 176 177 <div class="flex grow flex-col pb-2">{nodes}</div> 178
+13 -28
src/views/identity/did-lookup.tsx
··· 15 import ErrorView from '~/components/error-view'; 16 import Button from '~/components/inputs/button'; 17 import TextInput from '~/components/inputs/text-input'; 18 19 const DidLookupPage = () => { 20 const [params, setParams] = useSearchParams({ ··· 46 47 return ( 48 <> 49 - <div class="p-4"> 50 - <h1 class="text-lg font-bold text-purple-800">View identity info</h1> 51 - <p class="text-gray-600">Look up an account's DID document</p> 52 - </div> 53 - <hr class="mx-4 border-gray-300" /> 54 55 <form 56 onSubmit={(ev) => { ··· 133 134 <div class="mt-2 flex flex-wrap gap-2 empty:hidden"> 135 {isPDS && isServiceUrl && ( 136 - <button 137 - disabled 138 - class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50" 139 - > 140 View PDS info 141 - </button> 142 )} 143 144 {isPDS && isServiceUrl && ( 145 - <button 146 - disabled 147 - class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50" 148 - > 149 Explore account repository 150 - </button> 151 )} 152 153 {isLabeler && isServiceUrl && ( 154 - <button 155 - disabled 156 - class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100 disabled:pointer-events-none disabled:opacity-50" 157 - > 158 View emitted labels 159 - </button> 160 )} 161 </div> 162 </li> ··· 185 </div> 186 187 <div class="flex flex-wrap gap-4 p-4 pt-2"> 188 - <button 189 onClick={() => { 190 navigator.clipboard.writeText(JSON.stringify(doc, null, 2)); 191 }} 192 - class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100" 193 > 194 Copy DID document 195 - </button> 196 197 {isDidPlc && ( 198 - <a 199 - href={`/plc-oplogs?q=${params.q!}`} 200 - class="flex h-9 select-none items-center rounded border border-gray-300 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-100 active:bg-gray-100" 201 - > 202 View PLC operation logs 203 - </a> 204 )} 205 </div> 206 </>
··· 15 import ErrorView from '~/components/error-view'; 16 import Button from '~/components/inputs/button'; 17 import TextInput from '~/components/inputs/text-input'; 18 + import PageHeader from '~/components/page-header'; 19 20 const DidLookupPage = () => { 21 const [params, setParams] = useSearchParams({ ··· 47 48 return ( 49 <> 50 + <PageHeader title="View identity info" subtitle="Look up an account's DID document" /> 51 52 <form 53 onSubmit={(ev) => { ··· 130 131 <div class="mt-2 flex flex-wrap gap-2 empty:hidden"> 132 {isPDS && isServiceUrl && ( 133 + <Button variant="outline" disabled> 134 View PDS info 135 + </Button> 136 )} 137 138 {isPDS && isServiceUrl && ( 139 + <Button variant="outline" disabled> 140 Explore account repository 141 + </Button> 142 )} 143 144 {isLabeler && isServiceUrl && ( 145 + <Button variant="outline" disabled> 146 View emitted labels 147 + </Button> 148 )} 149 </div> 150 </li> ··· 173 </div> 174 175 <div class="flex flex-wrap gap-4 p-4 pt-2"> 176 + <Button 177 + variant="outline" 178 onClick={() => { 179 navigator.clipboard.writeText(JSON.stringify(doc, null, 2)); 180 }} 181 > 182 Copy DID document 183 + </Button> 184 185 {isDidPlc && ( 186 + <Button variant="outline" href={`/plc-oplogs?q=${params.q!}`}> 187 View PLC operation logs 188 + </Button> 189 )} 190 </div> 191 </>
+2 -5
src/views/identity/plc-applicator/page.tsx
··· 13 14 import { useTitle } from '~/lib/navigation/router'; 15 16 import { Wizard } from '~/components/wizard'; 17 18 import Step1_HandleInput from './steps/step1_handle-input'; ··· 101 102 return ( 103 <> 104 - <div class="p-4"> 105 - <h1 class="text-lg font-bold text-purple-800">Apply PLC operations</h1> 106 - <p class="text-gray-600">Submit operations to your did:plc identity</p> 107 - </div> 108 - <hr class="mx-4 border-gray-300" /> 109 110 <Wizard<PlcApplicatorConstraints> 111 initialStep="Step1_HandleInput"
··· 13 14 import { useTitle } from '~/lib/navigation/router'; 15 16 + import PageHeader from '~/components/page-header'; 17 import { Wizard } from '~/components/wizard'; 18 19 import Step1_HandleInput from './steps/step1_handle-input'; ··· 102 103 return ( 104 <> 105 + <PageHeader title="Apply PLC operations" subtitle="Submit operations to your did:plc identity" /> 106 107 <Wizard<PlcApplicatorConstraints> 108 initialStep="Step1_HandleInput"
+2 -5
src/views/identity/plc-oplogs.tsx
··· 20 import ContentCopyIcon from '~/components/ic-icons/baseline-content-copy'; 21 import Button from '~/components/inputs/button'; 22 import TextInput from '~/components/inputs/text-input'; 23 24 const PlcOperationLogPage = () => { 25 const [params, setParams] = useSearchParams({ ··· 55 56 return ( 57 <> 58 - <div class="p-4"> 59 - <h1 class="text-lg font-bold text-purple-800">View PLC operation logs</h1> 60 - <p class="text-gray-600">Show history of a did:plc identity</p> 61 - </div> 62 - <hr class="mx-4 border-gray-300" /> 63 64 <form 65 onSubmit={(ev) => {
··· 20 import ContentCopyIcon from '~/components/ic-icons/baseline-content-copy'; 21 import Button from '~/components/inputs/button'; 22 import TextInput from '~/components/inputs/text-input'; 23 + import PageHeader from '~/components/page-header'; 24 25 const PlcOperationLogPage = () => { 26 const [params, setParams] = useSearchParams({ ··· 56 57 return ( 58 <> 59 + <PageHeader title="View PLC operation logs" subtitle="Show history of a did:plc identity" /> 60 61 <form 62 onSubmit={(ev) => {
+8 -41
src/views/repository/repo-archive-explore/views/welcome.tsx
··· 3 import type { MutationReturn } from '~/lib/utils/mutation'; 4 5 import CircularProgress from '~/components/circular-progress'; 6 - import { createDropZone } from '~/lib/hooks/dropzone'; 7 8 import type { Archive } from '../types'; 9 ··· 12 } 13 14 const WelcomeView = ({ mutation }: WelcomeViewProps) => { 15 - const { ref: dropRef, isDropping } = createDropZone({ 16 - // Checked, the mime type for CAR files is blank. 17 - dataTypes: [''], 18 - multiple: false, 19 - onDrop(files) { 20 - if (files) { 21 - mutation.mutate({ file: files[0] }); 22 - } 23 - }, 24 - }); 25 - 26 return ( 27 <> 28 - <div class="p-4"> 29 - <h1 class="text-lg font-bold text-purple-800">Explore archive</h1> 30 - <p class="text-gray-600">Explore a repository archive</p> 31 - </div> 32 - <hr class="mx-4 border-gray-300" /> 33 34 <div class="flex flex-col gap-4 p-4"> 35 - <fieldset 36 - ref={dropRef} 37 - class={ 38 - `grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` + 39 - (!isDropping() ? ` bg-gray-100` : ` bg-green-100`) 40 - } 41 > 42 - <div class="flex flex-col items-center gap-4"> 43 - <button 44 - onClick={() => { 45 - const input = document.createElement('input'); 46 - input.type = 'file'; 47 - input.accept = '.car,application/vnd.ipld.car'; 48 - input.oninput = () => mutation.mutate({ file: input.files![0] }); 49 - 50 - input.click(); 51 - }} 52 - class="flex h-9 select-none items-center rounded border border-gray-400 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-200 active:bg-gray-200 disabled:pointer-events-none" 53 - > 54 - Browse files 55 - </button> 56 - <p class="select-none font-medium text-gray-600">or drop your file here</p> 57 - </div> 58 - 59 <div 60 hidden={!mutation.isPending} 61 class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-50" ··· 63 <CircularProgress /> 64 <span class="font-medium">Reading CAR file</span> 65 </div> 66 - </fieldset> 67 68 <Show when={mutation.error}> 69 <p class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800">
··· 3 import type { MutationReturn } from '~/lib/utils/mutation'; 4 5 import CircularProgress from '~/components/circular-progress'; 6 + import FileDropZone from '~/components/file-drop-zone'; 7 + import PageHeader from '~/components/page-header'; 8 9 import type { Archive } from '../types'; 10 ··· 13 } 14 15 const WelcomeView = ({ mutation }: WelcomeViewProps) => { 16 return ( 17 <> 18 + <PageHeader title="Explore archive" subtitle="Explore a repository archive" /> 19 20 <div class="flex flex-col gap-4 p-4"> 21 + <FileDropZone 22 + accept=".car,application/vnd.ipld.car" 23 + dataTypes={['']} 24 + onFiles={(files) => mutation.mutate({ file: files[0] })} 25 > 26 <div 27 hidden={!mutation.isPending} 28 class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-50" ··· 30 <CircularProgress /> 31 <span class="font-medium">Reading CAR file</span> 32 </div> 33 + </FileDropZone> 34 35 <Show when={mutation.error}> 36 <p class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800">
+8 -41
src/views/repository/repo-archive-unpack.tsx
··· 4 import { fromStream } from '@atcute/repo'; 5 import { writeTarEntry } from '@mary/tar'; 6 7 - import { createDropZone } from '~/lib/hooks/dropzone'; 8 import { useTitle } from '~/lib/navigation/router'; 9 import { makeAbortable } from '~/lib/utils/abortable'; 10 11 import Logger, { createLogger } from '~/components/logger'; 12 13 // @ts-expect-error: new API 14 const yieldToScheduler: () => Promise<void> = window?.scheduler?.yield ··· 21 22 const [getSignal, cleanup] = makeAbortable(); 23 const [pending, setPending] = createSignal(false); 24 - 25 - const { ref: dropRef, isDropping } = createDropZone({ 26 - // Checked, the mime type for CAR files is blank. 27 - dataTypes: [''], 28 - multiple: false, 29 - onDrop(files) { 30 - if (files) { 31 - onFileDrop(files); 32 - } 33 - }, 34 - }); 35 36 const mutate = async (file: File, signal: AbortSignal) => { 37 logger.log(`Starting extraction`); ··· 155 156 return ( 157 <> 158 - <div class="p-4"> 159 - <h1 class="text-lg font-bold text-purple-800">Unpack archive</h1> 160 - <p class="text-gray-600">Extract a repository archive into a tarball</p> 161 - </div> 162 - <hr class="mx-4 border-gray-300" /> 163 164 <div class="p-4"> 165 - <fieldset 166 - ref={dropRef} 167 disabled={pending()} 168 - class={ 169 - `grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` + 170 - (pending() || !isDropping() ? ` bg-gray-100` : ` bg-green-100`) 171 - } 172 - > 173 - <div class="flex flex-col items-center gap-4"> 174 - <button 175 - onClick={() => { 176 - const input = document.createElement('input'); 177 - input.type = 'file'; 178 - input.accept = '.car,application/vnd.ipld.car'; 179 - input.oninput = () => onFileDrop(Array.from(input.files!)); 180 - 181 - input.click(); 182 - }} 183 - class="flex h-9 select-none items-center rounded border border-gray-400 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-200 active:bg-gray-200 disabled:pointer-events-none" 184 - > 185 - Browse files 186 - </button> 187 - <p class="select-none font-medium text-gray-600">or drop your file here</p> 188 - </div> 189 - </fieldset> 190 </div> 191 <hr class="mx-4 border-gray-300" /> 192
··· 4 import { fromStream } from '@atcute/repo'; 5 import { writeTarEntry } from '@mary/tar'; 6 7 import { useTitle } from '~/lib/navigation/router'; 8 import { makeAbortable } from '~/lib/utils/abortable'; 9 10 + import FileDropZone from '~/components/file-drop-zone'; 11 import Logger, { createLogger } from '~/components/logger'; 12 + import PageHeader from '~/components/page-header'; 13 14 // @ts-expect-error: new API 15 const yieldToScheduler: () => Promise<void> = window?.scheduler?.yield ··· 22 23 const [getSignal, cleanup] = makeAbortable(); 24 const [pending, setPending] = createSignal(false); 25 26 const mutate = async (file: File, signal: AbortSignal) => { 27 logger.log(`Starting extraction`); ··· 145 146 return ( 147 <> 148 + <PageHeader title="Unpack archive" subtitle="Extract a repository archive into a tarball" /> 149 150 <div class="p-4"> 151 + <FileDropZone 152 + accept=".car,application/vnd.ipld.car" 153 + dataTypes={['']} 154 disabled={pending()} 155 + onFiles={onFileDrop} 156 + /> 157 </div> 158 <hr class="mx-4 border-gray-300" /> 159
+2 -5
src/views/repository/repo-export.tsx
··· 15 import Button from '~/components/inputs/button'; 16 import TextInput from '~/components/inputs/text-input'; 17 import Logger, { createLogger } from '~/components/logger'; 18 19 const RepoExportPage = () => { 20 const logger = createLogger(); ··· 135 136 return ( 137 <> 138 - <div class="p-4"> 139 - <h1 class="text-lg font-bold text-purple-800">Export repository</h1> 140 - <p class="text-gray-600">Download an archive of an account's repository</p> 141 - </div> 142 - <hr class="mx-4 border-gray-300" /> 143 144 <form 145 onSubmit={(ev) => {
··· 15 import Button from '~/components/inputs/button'; 16 import TextInput from '~/components/inputs/text-input'; 17 import Logger, { createLogger } from '~/components/logger'; 18 + import PageHeader from '~/components/page-header'; 19 20 const RepoExportPage = () => { 21 const logger = createLogger(); ··· 136 137 return ( 138 <> 139 + <PageHeader title="Export repository" subtitle="Download an archive of an account's repository" /> 140 141 <form 142 onSubmit={(ev) => {