Skip to content

Commit

Permalink
feat: missing files
Browse files Browse the repository at this point in the history
  • Loading branch information
alanshaw committed Jun 28, 2024
1 parent 8097e6e commit 1503238
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 0 deletions.
Binary file added public/nftstorage-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/web3storage-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
220 changes: 220 additions & 0 deletions src/app/migrations/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
'use client'

import { MouseEventHandler, useState } from 'react'
import { useRouter } from 'next/navigation'
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'
import { H1, H2 } from '@/components/Text'
import { MigrationConfiguration, MigrationSource, useMigrations } from '@/components/MigrationsProvider'
import { DIDKey, useW3 } from '@w3ui/react'
import { NFTStorage } from 'nft.storage'
import { Web3Storage } from 'web3.storage'
import { DidIcon } from '@/components/DidIcon'

interface WizardProps {
config: Partial<MigrationConfiguration>
onPrev: () => void
onNext: (config: Partial<MigrationConfiguration>) => void
}

const steps = [
ChooseSource,
AddSourceToken,
ChooseTargetSpace,
Confirmation
]

export default function CreateMigrationPage (): JSX.Element {
const [step, setStep] = useState(0)
const [config, setConfig] = useState<Partial<MigrationConfiguration>>({})
const router = useRouter()
const [, { createMigration }] = useMigrations()

const Component = steps[step]
const handlePrev = () => setStep(Math.max(0, step - 1))
const handleNext = (c: Partial<MigrationConfiguration>) => {
if (step === steps.length - 1) {
const { id } = createMigration(c as MigrationConfiguration)
router.replace(`/migration/${id}`)
} else {
setConfig(c)
setStep(step + 1)
}
}
return <Component config={config} onPrev={handlePrev} onNext={handleNext} />
}

function ChooseSource ({ config, onNext }: WizardProps) {
const [source, setSource] = useState<MigrationSource|undefined>(config.source)
const handleNextClick: MouseEventHandler = e => {
e.preventDefault()
if (!source) return
onNext({ ...config, source })
}

return (
<div>
<H1>Create a new migration</H1>
<p className='mb-8'>This allows data to be migrated from a previous provider to one of your spaces.</p>

<H2>Where from?</H2>
<p className='mb-4'>Pick a storage service you want to migrate data from.</p>
<div className='mb-4'>
<button className={`bg-white/60 rounded-lg shadow-md p-8 hover:outline mr-4 ${source === 'classic.nft.storage' ? 'outline' : ''}`} type='button' onClick={() => setSource('classic.nft.storage')} title='Migrate from NFT.Storage (Classic)'>
<img src='/nftstorage-logo.png' width='360' />
</button>
<button className={`bg-white/60 rounded-lg shadow-md p-8 hover:outline ${source === 'old.web3.storage' ? 'outline' : ''}`} type='button' onClick={() => setSource('old.web3.storage')} title='Migrate from Web3.Storage (Old)'>
<img src='/web3storage-logo.png' width='360' />
</button>
</div>
<button onClick={handleNextClick} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-6 pr-3 py-2 rounded-full whitespace-nowrap hover:outline ${source ? '' : 'opacity-10'}`} disabled={!source}>
Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle'/>
</button>
</div>
)
}

function AddSourceToken ({ config, onNext, onPrev }: WizardProps) {
const [token, setToken] = useState<string|undefined>(config.token)
const [error, setError] = useState('')

const handleNextClick: MouseEventHandler = async e => {
e.preventDefault()
if (!token) return
setError('')

try {
switch (config.source) {
case 'classic.nft.storage': {
const client = new NFTStorage({ token })
try {
await client.status(crypto.randomUUID())
} catch (err: any) {
if (!err.message.startsWith('Invalid CID')) {
throw err
}
}
break
}
case 'old.web3.storage': {
const client = new Web3Storage({ token })
console.log('list response', await client.list().next())
break
}
default:
throw new Error(`unknown data source: ${config.source}`)
}
} catch (err: any) {
console.error(err)
return setError(`Error using token: ${err.message}`)
}
onNext({ ...config, token })
}
return (
<div>
<H1>Add data source token</H1>
<p className='mb-8'>Add your <strong>{config.source}</strong> API token. Note: the key never leaves this device, it is for local use only by the migration tool.</p>
<H1>API Token</H1>
<div className='max-w-xl mb-4'>
<input
type='password'
className='text-black py-2 px-2 rounded block w-full border border-gray-800'
placeholder='eyJhb...'
value={token ?? ''}
onChange={e => setToken(e.target.value)}
required={true}
/>
<p className='text-xs text-red-700'>{error}</p>
</div>
<button onClick={e => { e.preventDefault(); onPrev() }} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-3 pr-6 py-2 mr-3 rounded-full whitespace-nowrap hover:outline`}>
<ChevronLeftIcon className='h-5 w-5 inline-block mr-1 align-middle'/> Previous
</button>
<button onClick={handleNextClick} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-6 pr-3 py-2 rounded-full whitespace-nowrap hover:outline ${token ? '' : 'opacity-10'}`} disabled={!token}>
Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle'/>
</button>
</div>
)
}

function ChooseTargetSpace ({ config, onNext, onPrev }: WizardProps) {
const [{ spaces }] = useW3()
const [space, setSpace] = useState<DIDKey|undefined>(config.space)

const handleNextClick: MouseEventHandler = async e => {
e.preventDefault()
if (!space) return
onNext({ ...config, space })
}
return (
<div>
<H1>Target space</H1>
<p className='mb-8'>Choose an existing space to migrate data to.</p>
<H2>Space</H2>
<div className='max-w-lg mb-4 border rounded-md border-zinc-700'>
{spaces.map(s => (
<button
type='button'
className={`flex flex-row items-start gap-2 p-3 text-white text-left border-b last:border-0 border-zinc-700 w-full ${s.did() === space ? 'bg-gray-900/60' : 'bg-gray-900/30 hover:bg-gray-900/50'}`}
onClick={() => setSpace(s.did())}>
<DidIcon did={s.did()} />
<div className='grow overflow-hidden whitespace-nowrap text-ellipsis'>
<span className='text-md font-semibold leading-5 m-0'>
{s.name || 'Untitled'}
</span>
<span className='font-mono text-xs block'>
{s.did()}
</span>
</div>
</button>
))}
</div>
<button onClick={e => { e.preventDefault(); onPrev() }} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-3 pr-6 py-2 mr-3 rounded-full whitespace-nowrap hover:outline`}>
<ChevronLeftIcon className='h-5 w-5 inline-block mr-1 align-middle'/> Previous
</button>
<button onClick={handleNextClick} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-6 pr-3 py-2 rounded-full whitespace-nowrap hover:outline ${space ? '' : 'opacity-10'}`} disabled={!space}>
Next <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle'/>
</button>
</div>
)
}

function Confirmation ({ config, onNext, onPrev }: WizardProps) {
const [{ spaces }] = useW3()
const space = spaces.find(s => s.did() === config.space)
if (!space) return

const handleNextClick: MouseEventHandler = async e => {
e.preventDefault()
onNext(config)
}
return (
<div>
<H1>Ready to start!</H1>
<p className='mb-8'>Make sure these details are correct before starting the migration.</p>
<H2>Source</H2>
<div className={`bg-white/60 rounded-lg shadow-md p-8 mb-4 inline-block`} title='Web3.Storage (Old)'>
<img src={config.source === 'old.web3.storage' ? '/web3storage-logo.png' : '/nftstorage-logo.png'} width='360' />
</div>
<H2>Target</H2>
<div className='max-w-lg mb-8 border rounded-md border-zinc-700'>
<div className={`flex flex-row items-start gap-2 p-3 text-white text-left border-b last:border-0 border-zinc-700 w-full bg-gray-900/30`}>
<DidIcon did={space.did()} />
<div className='grow overflow-hidden whitespace-nowrap text-ellipsis'>
<span className='text-md font-semibold leading-5 m-0'>
{space.name || 'Untitled'}
</span>
<span className='font-mono text-xs block'>
{space.did()}
</span>
</div>
</div>
</div>

<button onClick={e => { e.preventDefault(); onPrev() }} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-3 pr-6 py-2 mr-3 rounded-full whitespace-nowrap hover:outline`}>
<ChevronLeftIcon className='h-5 w-5 inline-block mr-1 align-middle'/> Previous
</button>
<button onClick={handleNextClick} className={`inline-block bg-zinc-950 text-white font-bold text-sm pl-6 pr-3 py-2 rounded-full whitespace-nowrap hover:outline ${space ? '' : 'opacity-10'}`} disabled={!space}>
Start <ChevronRightIcon className='h-5 w-5 inline-block ml-1 align-middle'/>
</button>
</div>
)
}
16 changes: 16 additions & 0 deletions src/app/migrations/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PropsWithChildren, ReactNode } from 'react'
import SidebarLayout from '@/components/SidebarLayout'

interface LayoutProps extends PropsWithChildren {
params: {
id: string
}
}

export default function Layout ({children}: LayoutProps): ReactNode {
return (
<SidebarLayout>
{children}
</SidebarLayout>
)
}
109 changes: 109 additions & 0 deletions src/components/MigrationsProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
'use client'

import React, { createContext, useContext, ReactNode, useState } from 'react'
import * as dagJSON from '@ipld/dag-json'
import { DIDKey } from '@w3ui/react'

export type MigrationSource = 'classic.nft.storage' | 'old.web3.storage'

export interface MigrationConfiguration {
/** Data source */
source: MigrationSource
/** API token for data source */
token: string
/** Target space to migrate data to */
space: DIDKey
}

export interface Migration extends MigrationConfiguration {
id: string
}

export interface ContextState {
migrations: Migration[]
}

export interface ContextActions {
createMigration: (config: MigrationConfiguration) => ({ id: string })
removeMigration: (id: string) => void
}

export type ContextValue = [
state: ContextState,
actions: ContextActions
]

export const MigrationContextDefaultValue: ContextValue = [
{
migrations: []
},
{
createMigration: () => { throw new Error('missing provider') },
removeMigration: () => { throw new Error('missing provider') }
}
]

export const Context = createContext<ContextValue>(
MigrationContextDefaultValue
)

export interface ProviderProps {
children?: ReactNode
}

export function Provider ({ children }: ProviderProps): ReactNode {
const migrationsStore = new MigrationsStorage()
const [migrations, setMigrations] = useState(migrationsStore.load())
const createMigration = (config: MigrationConfiguration) => {
const { id } = migrationsStore.create(config)
setMigrations(migrationsStore.load())
return { id }
}
const removeMigration = (id: string) => {
migrationsStore.remove(id)
setMigrations(migrationsStore.load())
}

return (
<Context.Provider value={[{ migrations }, { createMigration, removeMigration }]}>
{children}
</Context.Provider>
)
}

export function useMigrations (): ContextValue {
return useContext(Context)
}

class MigrationsStorage {
load () {
if (typeof localStorage === 'undefined') { // in dev there is SSR
return []
}
const ids: string[] = dagJSON.parse(localStorage.getItem('migrations') ?? '[]')
const migrations: Migration[] = []
for (const id of ids) {
try {
const migration: Migration = dagJSON.parse(localStorage.getItem(`migration.${id}`) ?? '')
migrations.push(migration)
} catch (err) {
console.error(`failed to load migration: ${id}`, err)
}
}
return migrations
}

create (config: MigrationConfiguration) {
const migration: Migration = { id: crypto.randomUUID(), ...config }
const ids: string[] = dagJSON.parse(localStorage.getItem('migrations') ?? '[]')
localStorage.setItem(`migration.${migration.id}`, dagJSON.stringify(migration))
localStorage.setItem('migrations', dagJSON.stringify([...ids, migration.id]))
return { id: migration.id }
}

remove (id: string) {
const ids: string[] = dagJSON.parse(localStorage.getItem('migrations') ?? '[]')
localStorage.setItem('migrations', dagJSON.stringify(ids.filter(i => i !== id)))
localStorage.removeItem(`migration.${id}`)
}
}
35 changes: 35 additions & 0 deletions src/components/SidebarMigrations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useRouter } from 'next/navigation'
import { useMigrations, Migration } from './MigrationsProvider'
import { H2 } from './Text'
import { PlusCircleIcon } from '@heroicons/react/20/solid'
import { DidIcon } from './DidIcon'

export const SidebarMigrations = () => {
const [{ migrations }] = useMigrations()
const router = useRouter()
return (
<>
<button type='button' className='float-right' onClick={e => { e.preventDefault(); router.push('/migrations/create') }}>
<PlusCircleIcon className='w-9 px-2 opacity-60 hover:opacity-100' style={{ marginTop: -2 }} title='Start a new migration' />
</button>
<H2 className='text-white'>Migrations</H2>
<MigrationsList migrations={migrations} />
</>
)
}

const MigrationsList = ({ migrations }: { migrations: Migration[] }) => {
const router = useRouter()
if (!migrations.length) {
return <p className='text-xs text-white/60'>No running migrations</p>
}
return migrations.map(m => {
return (
<button className='text-sm p-2 rounded bg-white/10 hover:outline align-middle w-full text-left mb-2' onClick={() => router.push(`/migrations/${m.id}`)}>
{m.source === 'classic.nft.storage' ? 'NFT' : 'w3s'}
<span className='mx-2'></span>
<DidIcon did={m.space} width={5} display='inline-block' />
</button>
)
})
}

0 comments on commit 1503238

Please sign in to comment.