-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
380 additions
and
0 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
}) | ||
} |