create-0g-app 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/package.json +33 -0
- package/templates/storage/README.md +106 -0
- package/templates/storage/_gitignore +20 -0
- package/templates/storage/package-lock.json +15358 -0
- package/templates/storage/package.json +15 -0
- package/templates/storage/packages/contracts/contracts/FileRegistry.sol +49 -0
- package/templates/storage/packages/contracts/hardhat.config.ts +40 -0
- package/templates/storage/packages/contracts/package.json +15 -0
- package/templates/storage/packages/contracts/scripts/deploy.ts +43 -0
- package/templates/storage/packages/web/.env.example +12 -0
- package/templates/storage/packages/web/app/globals.css +23 -0
- package/templates/storage/packages/web/app/layout.tsx +25 -0
- package/templates/storage/packages/web/app/page.module.css +74 -0
- package/templates/storage/packages/web/app/page.tsx +29 -0
- package/templates/storage/packages/web/app/providers.tsx +14 -0
- package/templates/storage/packages/web/app/storage/page.module.css +110 -0
- package/templates/storage/packages/web/app/storage/page.tsx +167 -0
- package/templates/storage/packages/web/components/Navbar.module.css +92 -0
- package/templates/storage/packages/web/components/Navbar.tsx +42 -0
- package/templates/storage/packages/web/lib/0g-storage.ts +40 -0
- package/templates/storage/packages/web/lib/abi.ts +48 -0
- package/templates/storage/packages/web/lib/wagmi.ts +23 -0
- package/templates/storage/packages/web/next-env.d.ts +6 -0
- package/templates/storage/packages/web/next.config.ts +27 -0
- package/templates/storage/packages/web/package.json +26 -0
- package/templates/storage/packages/web/tsconfig.json +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-0g-storage-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"workspaces": [
|
|
6
|
+
"packages/*"
|
|
7
|
+
],
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "npm run dev --workspace=packages/web",
|
|
10
|
+
"build": "npm run build --workspace=packages/web",
|
|
11
|
+
"deploy": "npm run compile --workspace=packages/contracts && npm run deploy --workspace=packages/contracts",
|
|
12
|
+
"verify": "npm run verify --workspace=packages/contracts",
|
|
13
|
+
"test": "npm run test --workspace=packages/contracts"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.24;
|
|
3
|
+
|
|
4
|
+
/// @title FileRegistry
|
|
5
|
+
/// @notice Records on-chain provenance for files stored on 0G decentralized storage.
|
|
6
|
+
/// The actual file bytes live off-chain; only the content ID (merkle root) is stored here.
|
|
7
|
+
contract FileRegistry {
|
|
8
|
+
struct FileRecord {
|
|
9
|
+
string contentId; // 0G storage merkle root / content identifier
|
|
10
|
+
string filename; // original filename for display purposes
|
|
11
|
+
uint256 timestamp;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// owner => list of file records
|
|
15
|
+
mapping(address => FileRecord[]) private _files;
|
|
16
|
+
|
|
17
|
+
event FileUploaded(
|
|
18
|
+
address indexed owner,
|
|
19
|
+
string contentId,
|
|
20
|
+
string filename,
|
|
21
|
+
uint256 timestamp
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/// @notice Register a file that has been uploaded to 0G storage.
|
|
25
|
+
/// @param contentId The merkle root / content ID returned by the 0G storage SDK.
|
|
26
|
+
/// @param filename The original filename (for display only).
|
|
27
|
+
function registerFile(string calldata contentId, string calldata filename) external {
|
|
28
|
+
require(bytes(contentId).length > 0, "FileRegistry: contentId required");
|
|
29
|
+
require(bytes(filename).length > 0, "FileRegistry: filename required");
|
|
30
|
+
|
|
31
|
+
_files[msg.sender].push(FileRecord({
|
|
32
|
+
contentId: contentId,
|
|
33
|
+
filename: filename,
|
|
34
|
+
timestamp: block.timestamp
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
emit FileUploaded(msg.sender, contentId, filename, block.timestamp);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// @notice Return all file records for a given owner.
|
|
41
|
+
function getFiles(address owner) external view returns (FileRecord[] memory) {
|
|
42
|
+
return _files[owner];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// @notice Return the total number of files registered by an owner.
|
|
46
|
+
function getFileCount(address owner) external view returns (uint256) {
|
|
47
|
+
return _files[owner].length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { HardhatUserConfig } from "hardhat/config";
|
|
2
|
+
import "@nomicfoundation/hardhat-toolbox";
|
|
3
|
+
import dotenv from "dotenv";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
// Load .env from the web package (single source of truth)
|
|
7
|
+
dotenv.config({ path: path.resolve(__dirname, "../web/.env.local") });
|
|
8
|
+
|
|
9
|
+
const config: HardhatUserConfig = {
|
|
10
|
+
solidity: {
|
|
11
|
+
version: "0.8.24",
|
|
12
|
+
settings: {
|
|
13
|
+
optimizer: { enabled: true, runs: 200 },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
networks: {
|
|
17
|
+
"0g-galileo": {
|
|
18
|
+
url: process.env.NEXT_PUBLIC_RPC_URL ?? "https://evmrpc-test.0g.ai",
|
|
19
|
+
chainId: 16601,
|
|
20
|
+
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
etherscan: {
|
|
24
|
+
apiKey: {
|
|
25
|
+
"0g-galileo": "no-api-key-needed",
|
|
26
|
+
},
|
|
27
|
+
customChains: [
|
|
28
|
+
{
|
|
29
|
+
network: "0g-galileo",
|
|
30
|
+
chainId: 16601,
|
|
31
|
+
urls: {
|
|
32
|
+
apiURL: "https://chainscan-galileo.0g.ai/api",
|
|
33
|
+
browserURL: "https://chainscan-galileo.0g.ai",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export default config;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "contracts",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"compile": "hardhat compile",
|
|
7
|
+
"deploy": "hardhat run scripts/deploy.ts --network 0g-galileo",
|
|
8
|
+
"verify": "hardhat verify --network 0g-galileo $(cat .deployed-address)",
|
|
9
|
+
"test": "hardhat test"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
|
|
13
|
+
"hardhat": "^2.22.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ethers } from "hardhat";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
const [deployer] = await ethers.getSigners();
|
|
7
|
+
console.log("Deploying FileRegistry with account:", deployer.address);
|
|
8
|
+
|
|
9
|
+
const balance = await ethers.provider.getBalance(deployer.address);
|
|
10
|
+
console.log("Account balance:", ethers.formatEther(balance), "OG");
|
|
11
|
+
|
|
12
|
+
const FileRegistry = await ethers.getContractFactory("FileRegistry");
|
|
13
|
+
const registry = await FileRegistry.deploy();
|
|
14
|
+
await registry.waitForDeployment();
|
|
15
|
+
|
|
16
|
+
const address = await registry.getAddress();
|
|
17
|
+
console.log("FileRegistry deployed to:", address);
|
|
18
|
+
|
|
19
|
+
// Write the address to a file so `npm run verify` can pick it up
|
|
20
|
+
fs.writeFileSync(path.join(__dirname, "../.deployed-address"), address);
|
|
21
|
+
|
|
22
|
+
// Also update the web package's .env.local so the frontend picks it up immediately
|
|
23
|
+
const envPath = path.join(__dirname, "../../web/.env.local");
|
|
24
|
+
if (fs.existsSync(envPath)) {
|
|
25
|
+
let env = fs.readFileSync(envPath, "utf8");
|
|
26
|
+
if (env.includes("NEXT_PUBLIC_CONTRACT_ADDRESS=")) {
|
|
27
|
+
env = env.replace(/NEXT_PUBLIC_CONTRACT_ADDRESS=.*/, `NEXT_PUBLIC_CONTRACT_ADDRESS=${address}`);
|
|
28
|
+
} else {
|
|
29
|
+
env += `\nNEXT_PUBLIC_CONTRACT_ADDRESS=${address}`;
|
|
30
|
+
}
|
|
31
|
+
fs.writeFileSync(envPath, env);
|
|
32
|
+
console.log("Updated web/.env.local with contract address");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log("\nNext steps:");
|
|
36
|
+
console.log(` npm run verify — verify on 0G explorer`);
|
|
37
|
+
console.log(` View on explorer: https://chainscan-galileo.0g.ai/address/${address}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
main().catch((err) => {
|
|
41
|
+
console.error(err);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 0G Galileo Testnet
|
|
2
|
+
NEXT_PUBLIC_CHAIN_ID=16601
|
|
3
|
+
NEXT_PUBLIC_RPC_URL=https://evmrpc-test.0g.ai
|
|
4
|
+
NEXT_PUBLIC_STORAGE_INDEXER=https://indexer-storage-testnet-standard.0g.ai
|
|
5
|
+
NEXT_PUBLIC_STORAGE_RPC=https://rpc-storage-testnet.0g.ai
|
|
6
|
+
|
|
7
|
+
# Populated automatically after `npm run deploy` from the repo root
|
|
8
|
+
NEXT_PUBLIC_CONTRACT_ADDRESS=
|
|
9
|
+
|
|
10
|
+
# Your wallet private key — used only by Hardhat deploy scripts, never exposed to the browser
|
|
11
|
+
# Get testnet OG tokens at https://faucet.0g.ai
|
|
12
|
+
PRIVATE_KEY=
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #0a0a0f;
|
|
5
|
+
--surface: #12121a;
|
|
6
|
+
--border: #2a2a3a;
|
|
7
|
+
--accent: #00d4ff;
|
|
8
|
+
--accent-dim: rgba(0, 212, 255, 0.12);
|
|
9
|
+
--text: #e8e8f0;
|
|
10
|
+
--muted: #6b6b80;
|
|
11
|
+
--error: #ff4444;
|
|
12
|
+
--success: #00c896;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
background: var(--bg);
|
|
17
|
+
color: var(--text);
|
|
18
|
+
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
|
19
|
+
min-height: 100vh;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
a { color: var(--accent); text-decoration: none; }
|
|
23
|
+
a:hover { text-decoration: underline; }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist } from "next/font/google";
|
|
3
|
+
import { Providers } from "./providers";
|
|
4
|
+
import { Navbar } from "@/components/Navbar";
|
|
5
|
+
import "./globals.css";
|
|
6
|
+
|
|
7
|
+
const geist = Geist({ subsets: ["latin"] });
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = {
|
|
10
|
+
title: "create-0g-app",
|
|
11
|
+
description: "Decentralized app powered by 0G",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
15
|
+
return (
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<body className={geist.className}>
|
|
18
|
+
<Providers>
|
|
19
|
+
<Navbar />
|
|
20
|
+
{children}
|
|
21
|
+
</Providers>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
.main {
|
|
2
|
+
min-height: calc(100vh - 56px);
|
|
3
|
+
display: flex;
|
|
4
|
+
align-items: center;
|
|
5
|
+
justify-content: center;
|
|
6
|
+
padding: 24px;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.card {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: 28px;
|
|
13
|
+
max-width: 480px;
|
|
14
|
+
width: 100%;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.title {
|
|
18
|
+
font-size: 2.2rem;
|
|
19
|
+
font-weight: 700;
|
|
20
|
+
letter-spacing: -0.04em;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.accent { color: var(--accent); }
|
|
24
|
+
|
|
25
|
+
.subtitle {
|
|
26
|
+
color: var(--muted);
|
|
27
|
+
font-size: 0.95rem;
|
|
28
|
+
margin-top: -16px;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.steps {
|
|
32
|
+
list-style: none;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
gap: 12px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.steps li {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 6px;
|
|
42
|
+
background: var(--surface);
|
|
43
|
+
border: 1px solid var(--border);
|
|
44
|
+
border-radius: 10px;
|
|
45
|
+
padding: 14px 16px;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.label {
|
|
49
|
+
font-size: 0.75rem;
|
|
50
|
+
font-weight: 600;
|
|
51
|
+
text-transform: uppercase;
|
|
52
|
+
letter-spacing: 0.08em;
|
|
53
|
+
color: var(--accent);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.path {
|
|
57
|
+
font-family: monospace;
|
|
58
|
+
font-size: 0.9rem;
|
|
59
|
+
color: var(--text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.links {
|
|
63
|
+
display: flex;
|
|
64
|
+
gap: 20px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.links a {
|
|
68
|
+
font-size: 0.85rem;
|
|
69
|
+
color: var(--muted);
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
transition: color 0.15s;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.links a:hover { color: var(--accent); }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import styles from "./page.module.css";
|
|
2
|
+
|
|
3
|
+
export default function Home() {
|
|
4
|
+
return (
|
|
5
|
+
<main className={styles.main}>
|
|
6
|
+
<div className={styles.card}>
|
|
7
|
+
<h1 className={styles.title}>
|
|
8
|
+
<span className={styles.accent}>create</span>-0g-app
|
|
9
|
+
</h1>
|
|
10
|
+
<p className={styles.subtitle}>Your 0G app is ready. Start editing:</p>
|
|
11
|
+
<ul className={styles.steps}>
|
|
12
|
+
<li>
|
|
13
|
+
<span className={styles.label}>Frontend</span>
|
|
14
|
+
<code className={styles.path}>packages/web/app/page.tsx</code>
|
|
15
|
+
</li>
|
|
16
|
+
<li>
|
|
17
|
+
<span className={styles.label}>Contract</span>
|
|
18
|
+
<code className={styles.path}>packages/contracts/contracts/FileRegistry.sol</code>
|
|
19
|
+
</li>
|
|
20
|
+
</ul>
|
|
21
|
+
<div className={styles.links}>
|
|
22
|
+
<a href="https://docs.0g.ai" target="_blank" rel="noopener noreferrer">Docs ↗</a>
|
|
23
|
+
<a href="https://faucet.0g.ai" target="_blank" rel="noopener noreferrer">Faucet ↗</a>
|
|
24
|
+
<a href="https://chainscan-galileo.0g.ai" target="_blank" rel="noopener noreferrer">Explorer ↗</a>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</main>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { WagmiProvider } from "wagmi";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { wagmiConfig } from "@/lib/wagmi";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
8
|
+
const [queryClient] = useState(() => new QueryClient());
|
|
9
|
+
return (
|
|
10
|
+
<WagmiProvider config={wagmiConfig}>
|
|
11
|
+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
12
|
+
</WagmiProvider>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
.main {
|
|
2
|
+
max-width: 800px;
|
|
3
|
+
margin: 0 auto;
|
|
4
|
+
padding: 32px 20px 80px;
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
gap: 24px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.connectPrompt {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
justify-content: center;
|
|
14
|
+
min-height: calc(100vh - 56px);
|
|
15
|
+
color: var(--muted);
|
|
16
|
+
font-size: 0.95rem;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.warning {
|
|
20
|
+
background: rgba(255, 200, 0, 0.08);
|
|
21
|
+
border: 1px solid rgba(255, 200, 0, 0.3);
|
|
22
|
+
color: #ffc800;
|
|
23
|
+
padding: 12px 16px;
|
|
24
|
+
border-radius: 10px;
|
|
25
|
+
font-size: 0.85rem;
|
|
26
|
+
}
|
|
27
|
+
.warning code {
|
|
28
|
+
font-family: monospace;
|
|
29
|
+
background: rgba(0,0,0,0.3);
|
|
30
|
+
padding: 2px 6px;
|
|
31
|
+
border-radius: 4px;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.dropzone {
|
|
35
|
+
border: 2px dashed var(--border);
|
|
36
|
+
border-radius: 16px;
|
|
37
|
+
padding: 48px 24px;
|
|
38
|
+
text-align: center;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
transition: border-color 0.2s, background 0.2s;
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: column;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: 8px;
|
|
45
|
+
}
|
|
46
|
+
.dropzone:hover, .dropzoneOver {
|
|
47
|
+
border-color: var(--accent);
|
|
48
|
+
background: var(--accent-dim);
|
|
49
|
+
}
|
|
50
|
+
.dropzoneIcon { font-size: 2rem; color: var(--accent); }
|
|
51
|
+
.dropzoneHint { font-size: 0.8rem; color: var(--muted); }
|
|
52
|
+
.link { color: var(--accent); }
|
|
53
|
+
|
|
54
|
+
.statusBar {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
gap: 10px;
|
|
58
|
+
padding: 12px 16px;
|
|
59
|
+
border-radius: 10px;
|
|
60
|
+
font-size: 0.9rem;
|
|
61
|
+
background: var(--surface);
|
|
62
|
+
border: 1px solid var(--border);
|
|
63
|
+
}
|
|
64
|
+
.uploading-storage, .registering { border-color: var(--accent); color: var(--accent); }
|
|
65
|
+
.done { border-color: var(--success); color: var(--success); }
|
|
66
|
+
.error { border-color: var(--error); color: var(--error); }
|
|
67
|
+
.explorerLink { margin-left: auto; font-size: 0.8rem; color: var(--accent); }
|
|
68
|
+
|
|
69
|
+
.spinner {
|
|
70
|
+
width: 14px; height: 14px;
|
|
71
|
+
border: 2px solid currentColor;
|
|
72
|
+
border-top-color: transparent;
|
|
73
|
+
border-radius: 50%;
|
|
74
|
+
animation: spin 0.7s linear infinite;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
}
|
|
77
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
78
|
+
|
|
79
|
+
.gallery { display: flex; flex-direction: column; gap: 16px; }
|
|
80
|
+
.gallery h2 { font-size: 1.1rem; font-weight: 600; }
|
|
81
|
+
.empty { color: var(--muted); font-size: 0.9rem; }
|
|
82
|
+
|
|
83
|
+
.fileList { list-style: none; display: flex; flex-direction: column; gap: 10px; }
|
|
84
|
+
.fileItem {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: space-between;
|
|
88
|
+
background: var(--surface);
|
|
89
|
+
border: 1px solid var(--border);
|
|
90
|
+
border-radius: 10px;
|
|
91
|
+
padding: 14px 16px;
|
|
92
|
+
transition: border-color 0.15s;
|
|
93
|
+
}
|
|
94
|
+
.fileItem:hover { border-color: var(--accent); }
|
|
95
|
+
.fileInfo { display: flex; flex-direction: column; gap: 4px; }
|
|
96
|
+
.fileName { font-weight: 500; font-size: 0.95rem; }
|
|
97
|
+
.fileContentId { font-family: monospace; font-size: 0.78rem; color: var(--muted); }
|
|
98
|
+
.fileDate { font-size: 0.78rem; color: var(--muted); }
|
|
99
|
+
|
|
100
|
+
.btnSecondary {
|
|
101
|
+
background: transparent;
|
|
102
|
+
color: var(--text);
|
|
103
|
+
border: 1px solid var(--border);
|
|
104
|
+
padding: 6px 14px;
|
|
105
|
+
border-radius: 8px;
|
|
106
|
+
font-size: 0.85rem;
|
|
107
|
+
cursor: pointer;
|
|
108
|
+
transition: border-color 0.15s;
|
|
109
|
+
}
|
|
110
|
+
.btnSecondary:hover { border-color: var(--accent); color: var(--accent); }
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useAccount, useReadContract, useWriteContract } from "wagmi";
|
|
3
|
+
import { useState, useCallback, useRef } from "react";
|
|
4
|
+
import { BrowserProvider } from "ethers";
|
|
5
|
+
import { uploadFile, downloadFile } from "@/lib/0g-storage";
|
|
6
|
+
import { FILE_REGISTRY_ABI } from "@/lib/abi";
|
|
7
|
+
import styles from "./page.module.css";
|
|
8
|
+
|
|
9
|
+
const CONTRACT = (process.env.NEXT_PUBLIC_CONTRACT_ADDRESS ?? "") as `0x${string}`;
|
|
10
|
+
|
|
11
|
+
type FileRecord = { contentId: string; filename: string; timestamp: bigint };
|
|
12
|
+
type UploadStatus = "idle" | "uploading-storage" | "registering" | "done" | "error";
|
|
13
|
+
|
|
14
|
+
export default function StoragePage() {
|
|
15
|
+
const { address, isConnected } = useAccount();
|
|
16
|
+
|
|
17
|
+
const [dragOver, setDragOver] = useState(false);
|
|
18
|
+
const [status, setStatus] = useState<UploadStatus>("idle");
|
|
19
|
+
const [statusMsg, setStatusMsg] = useState("");
|
|
20
|
+
const [lastContentId, setLastContentId] = useState("");
|
|
21
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
const { data: files, refetch } = useReadContract({
|
|
24
|
+
address: CONTRACT,
|
|
25
|
+
abi: FILE_REGISTRY_ABI,
|
|
26
|
+
functionName: "getFiles",
|
|
27
|
+
args: address ? [address] : undefined,
|
|
28
|
+
query: { enabled: !!address && !!CONTRACT },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { writeContractAsync } = useWriteContract();
|
|
32
|
+
|
|
33
|
+
const handleFile = useCallback(
|
|
34
|
+
async (file: File) => {
|
|
35
|
+
if (!isConnected || !address) return;
|
|
36
|
+
try {
|
|
37
|
+
setStatus("uploading-storage");
|
|
38
|
+
setStatusMsg(`Uploading "${file.name}" to 0G storage…`);
|
|
39
|
+
|
|
40
|
+
const provider = new BrowserProvider(window.ethereum as Parameters<typeof BrowserProvider>[0]);
|
|
41
|
+
const signer = await provider.getSigner();
|
|
42
|
+
|
|
43
|
+
const { contentId } = await uploadFile(file, signer);
|
|
44
|
+
setLastContentId(contentId);
|
|
45
|
+
|
|
46
|
+
setStatus("registering");
|
|
47
|
+
setStatusMsg("Registering on-chain…");
|
|
48
|
+
|
|
49
|
+
await writeContractAsync({
|
|
50
|
+
address: CONTRACT,
|
|
51
|
+
abi: FILE_REGISTRY_ABI,
|
|
52
|
+
functionName: "registerFile",
|
|
53
|
+
args: [contentId, file.name],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
setStatus("done");
|
|
57
|
+
setStatusMsg(`Done! Content ID: ${contentId.slice(0, 18)}…`);
|
|
58
|
+
refetch();
|
|
59
|
+
} catch (e) {
|
|
60
|
+
setStatus("error");
|
|
61
|
+
setStatusMsg(e instanceof Error ? e.message : "Unknown error");
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[isConnected, address, writeContractAsync, refetch]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const onDrop = useCallback(
|
|
68
|
+
(e: React.DragEvent) => {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setDragOver(false);
|
|
71
|
+
const file = e.dataTransfer.files[0];
|
|
72
|
+
if (file) handleFile(file);
|
|
73
|
+
},
|
|
74
|
+
[handleFile]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const handleDownload = async (record: FileRecord) => {
|
|
78
|
+
const blob = await downloadFile(record.contentId);
|
|
79
|
+
const url = URL.createObjectURL(blob);
|
|
80
|
+
const a = document.createElement("a");
|
|
81
|
+
a.href = url;
|
|
82
|
+
a.download = record.filename;
|
|
83
|
+
a.click();
|
|
84
|
+
URL.revokeObjectURL(url);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (!isConnected) {
|
|
88
|
+
return (
|
|
89
|
+
<div className={styles.connectPrompt}>
|
|
90
|
+
Connect your wallet to upload and view files.
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<main className={styles.main}>
|
|
97
|
+
{!CONTRACT && (
|
|
98
|
+
<div className={styles.warning}>
|
|
99
|
+
⚠ No contract deployed yet. Run <code>npm run deploy</code> from the repo root, then restart the dev server.
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<div
|
|
104
|
+
className={`${styles.dropzone} ${dragOver ? styles.dropzoneOver : ""}`}
|
|
105
|
+
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
106
|
+
onDragLeave={() => setDragOver(false)}
|
|
107
|
+
onDrop={onDrop}
|
|
108
|
+
onClick={() => fileInputRef.current?.click()}
|
|
109
|
+
>
|
|
110
|
+
<input
|
|
111
|
+
ref={fileInputRef}
|
|
112
|
+
type="file"
|
|
113
|
+
style={{ display: "none" }}
|
|
114
|
+
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
|
115
|
+
/>
|
|
116
|
+
<div className={styles.dropzoneIcon}>↑</div>
|
|
117
|
+
<p>Drop a file here or <span className={styles.link}>click to browse</span></p>
|
|
118
|
+
<p className={styles.dropzoneHint}>Any file type · Stored on 0G · Provenance on-chain</p>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{status !== "idle" && (
|
|
122
|
+
<div className={`${styles.statusBar} ${styles[status]}`}>
|
|
123
|
+
{(status === "uploading-storage" || status === "registering") && (
|
|
124
|
+
<span className={styles.spinner} />
|
|
125
|
+
)}
|
|
126
|
+
{statusMsg}
|
|
127
|
+
{status === "done" && lastContentId && (
|
|
128
|
+
<a
|
|
129
|
+
className={styles.explorerLink}
|
|
130
|
+
href="https://chainscan-galileo.0g.ai"
|
|
131
|
+
target="_blank"
|
|
132
|
+
rel="noopener noreferrer"
|
|
133
|
+
>
|
|
134
|
+
View on explorer ↗
|
|
135
|
+
</a>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
<section className={styles.gallery}>
|
|
141
|
+
<h2>Your Files</h2>
|
|
142
|
+
{!files || files.length === 0 ? (
|
|
143
|
+
<p className={styles.empty}>No files uploaded yet. Drop one above to get started.</p>
|
|
144
|
+
) : (
|
|
145
|
+
<ul className={styles.fileList}>
|
|
146
|
+
{[...(files as FileRecord[])].reverse().map((f, i) => (
|
|
147
|
+
<li key={i} className={styles.fileItem}>
|
|
148
|
+
<div className={styles.fileInfo}>
|
|
149
|
+
<span className={styles.fileName}>{f.filename}</span>
|
|
150
|
+
<span className={styles.fileContentId} title={f.contentId}>
|
|
151
|
+
{f.contentId.slice(0, 20)}…
|
|
152
|
+
</span>
|
|
153
|
+
<span className={styles.fileDate}>
|
|
154
|
+
{new Date(Number(f.timestamp) * 1000).toLocaleString()}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<button className={styles.btnSecondary} onClick={() => handleDownload(f)}>
|
|
158
|
+
Download
|
|
159
|
+
</button>
|
|
160
|
+
</li>
|
|
161
|
+
))}
|
|
162
|
+
</ul>
|
|
163
|
+
)}
|
|
164
|
+
</section>
|
|
165
|
+
</main>
|
|
166
|
+
);
|
|
167
|
+
}
|