feat: Implement IPFS functionality using Helia
- Add Helia and related dependencies for IPFS integration. - Create IPFS service module for core IPFS operations. - Create IPFS context provider for application-wide access. - Modify MarkdownContent component to fetch from IPFS. - Create IPFS uploader component for content upload. - Create IPFS gateway fallback for offline access. - Modify NavDataProvider to load from IPFS. - Implement offline support and local caching. - Create Network Status Service to monitor network status. - Create Offline Status Component to display offline status. - Implement Service Worker for caching app assets. - Create Offline page.
This commit is contained in:
parent
f22e9faae2
commit
3e1822247d
1446
ipfs-implementation-plan.md
Normal file
1446
ipfs-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
1361
sweb/bun.lock
1361
sweb/bun.lock
File diff suppressed because it is too large
Load Diff
@ -26,12 +26,21 @@
|
||||
"vite": "^6.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@helia/interface": "^5.2.1",
|
||||
"@helia/unixfs": "^5.0.0",
|
||||
"@libp2p/webrtc": "^5.2.12",
|
||||
"@libp2p/websockets": "^9.2.10",
|
||||
"@libp2p/webtransport": "^5.0.40",
|
||||
"@tailwindcss/postcss": "^4.1.6",
|
||||
"@tailwindcss/vite": "^4.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"helia": "^5.3.0",
|
||||
"idb": "^8.0.3",
|
||||
"libp2p": "^2.8.5",
|
||||
"lucide-svelte": "^0.509.0",
|
||||
"marked": "^15.0.11",
|
||||
"multiformats": "^13.3.3",
|
||||
"shadcn-svelte": "^0.14.2",
|
||||
"tailwind-merge": "^3.2.0"
|
||||
}
|
||||
|
@ -1,9 +1,17 @@
|
||||
<script lang="ts">
|
||||
import Layout from "./components/Layout.svelte";
|
||||
import Home from "./components/Home.svelte";
|
||||
import OfflineStatus from "./components/OfflineStatus.svelte";
|
||||
import * as IPFSProviderModule from "./components/IPFSProvider.svelte";
|
||||
import "./app.css";
|
||||
|
||||
// Use the component from the module
|
||||
const IPFSProvider = IPFSProviderModule.default;
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<Home />
|
||||
</Layout>
|
||||
<IPFSProvider>
|
||||
<Layout>
|
||||
<Home contentPath="" />
|
||||
</Layout>
|
||||
<OfflineStatus />
|
||||
</IPFSProvider>
|
||||
|
@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import MarkdownContent from "./MarkdownContent.svelte";
|
||||
import { Shield, Zap, Smartphone } from "lucide-svelte";
|
||||
import IPFSDemo from "./IPFSDemo.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
export let contentPath: string = "introduction/introduction";
|
||||
|
||||
// Default to introduction if no path is provided
|
||||
$: actualPath = contentPath || "introduction/introduction";
|
||||
|
||||
// Flag to show IPFS demo
|
||||
let showIPFSDemo = false;
|
||||
</script>
|
||||
|
||||
<div class="">
|
||||
@ -16,162 +20,35 @@
|
||||
<MarkdownContent path={actualPath} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero Section -->
|
||||
<section class="mb-12 sm:mb-16 md:mb-20 text-center md:text-left">
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="md:w-1/2 mb-8 md:mb-0 md:pr-6">
|
||||
<h1
|
||||
class="text-3xl sm:text-4xl md:text-5xl font-bold mb-4 sm:mb-6 text-text leading-tight"
|
||||
>
|
||||
Welcome to <span class="text-primary-600"
|
||||
>SecureWeb</span
|
||||
>
|
||||
</h1>
|
||||
<p
|
||||
class="text-lg sm:text-xl text-text-secondary max-w-2xl mb-6 sm:mb-8 leading-relaxed"
|
||||
>
|
||||
A comprehensive platform for secure and reliable web
|
||||
solutions designed for modern businesses.
|
||||
</p>
|
||||
<div
|
||||
class="flex flex-col xs:flex-row justify-center md:justify-start gap-3 sm:gap-4"
|
||||
>
|
||||
<button
|
||||
class="bg-primary-600 hover:bg-primary-700 text-white px-6 sm:px-8 py-2.5 sm:py-3 rounded-md font-medium transition-colors duration-200 w-full xs:w-auto"
|
||||
>Get Started</button
|
||||
>
|
||||
<button
|
||||
class="bg-background-secondary hover:bg-background text-text px-6 sm:px-8 py-2.5 sm:py-3 rounded-md font-medium border border-border transition-colors duration-200 w-full xs:w-auto"
|
||||
>Learn More</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<img
|
||||
src="https://via.placeholder.com/600x400/f3f4f6/1e40af?text=SecureWeb"
|
||||
alt="SecureWeb Platform"
|
||||
class="rounded-lg shadow-lg w-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features Section -->
|
||||
<!-- IPFS Demo Section -->
|
||||
<section class="mb-12 sm:mb-16 md:mb-20">
|
||||
<div class="text-center mb-8 sm:mb-12">
|
||||
<h2
|
||||
class="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4 text-text"
|
||||
>
|
||||
Our Features
|
||||
IPFS Integration
|
||||
</h2>
|
||||
<p
|
||||
class="text-lg sm:text-xl text-text-secondary max-w-3xl mx-auto"
|
||||
>
|
||||
Discover what makes SecureWeb the preferred choice for
|
||||
security-conscious businesses.
|
||||
Experience the power of decentralized content storage and
|
||||
retrieval with IPFS.
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
on:click={() => (showIPFSDemo = !showIPFSDemo)}
|
||||
>
|
||||
{showIPFSDemo ? "Hide IPFS Demo" : "Show IPFS Demo"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 md:gap-10"
|
||||
>
|
||||
<div
|
||||
class="bg-background p-6 sm:p-8 rounded-xl shadow-md hover:shadow-lg transition-shadow border border-border"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-100 p-3 rounded-full w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center mb-4 sm:mb-6"
|
||||
>
|
||||
<Shield
|
||||
class="h-6 w-6 sm:h-7 sm:w-7 text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 text-text"
|
||||
>
|
||||
Advanced Security
|
||||
</h3>
|
||||
<p
|
||||
class="text-text-secondary leading-relaxed text-sm sm:text-base"
|
||||
>
|
||||
State-of-the-art encryption and security protocols to
|
||||
keep your data protected from threats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-background-secondary p-6 sm:p-8 rounded-xl shadow-md hover:shadow-lg transition-shadow border border-border"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-100 p-3 rounded-full w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center mb-4 sm:mb-6"
|
||||
>
|
||||
<Zap class="h-6 w-6 sm:h-7 sm:w-7 text-primary-600" />
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 text-text"
|
||||
>
|
||||
Lightning Performance
|
||||
</h3>
|
||||
<p
|
||||
class="text-text-secondary leading-relaxed text-sm sm:text-base"
|
||||
>
|
||||
Optimized for speed and efficiency, ensuring a smooth
|
||||
and responsive user experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-background p-6 sm:p-8 rounded-xl shadow-md hover:shadow-lg transition-shadow border border-border sm:col-span-2 md:col-span-1"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-100 p-3 rounded-full w-12 h-12 sm:w-14 sm:h-14 flex items-center justify-center mb-4 sm:mb-6"
|
||||
>
|
||||
<Smartphone
|
||||
class="h-6 w-6 sm:h-7 sm:w-7 text-primary-600"
|
||||
/>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg sm:text-xl font-bold mb-3 sm:mb-4 text-text"
|
||||
>
|
||||
Responsive Design
|
||||
</h3>
|
||||
<p
|
||||
class="text-text-secondary leading-relaxed text-sm sm:text-base"
|
||||
>
|
||||
Fully responsive layouts that provide a seamless
|
||||
experience across all devices and screen sizes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<section>
|
||||
<div
|
||||
class="bg-gradient-to-r from-primary-600 to-primary-800 rounded-xl sm:rounded-2xl p-6 sm:p-8 md:p-10 text-white shadow-lg sm:shadow-xl"
|
||||
>
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="mb-6 md:mb-0 md:w-2/3 md:pr-6">
|
||||
<h2 class="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p
|
||||
class="text-base sm:text-lg text-primary-100 mb-0 max-w-2xl"
|
||||
>
|
||||
Join thousands of satisfied users who trust
|
||||
SecureWeb for their web security needs. Sign up
|
||||
today and experience the difference.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-center md:justify-end">
|
||||
<button
|
||||
class="bg-background-secondary text-primary-700 hover:bg-primary-50 px-6 sm:px-8 py-2.5 sm:py-3 rounded-lg font-semibold text-base sm:text-lg shadow-md transition-colors w-full xs:w-auto"
|
||||
>
|
||||
Sign Up Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if showIPFSDemo}
|
||||
<IPFSDemo />
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
153
sweb/src/components/IPFSDemo.svelte
Normal file
153
sweb/src/components/IPFSDemo.svelte
Normal file
@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { ipfsService } from "../services/ipfs.service";
|
||||
import { onMount } from "svelte";
|
||||
import MarkdownContent from "./MarkdownContent.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
let content = "";
|
||||
let uploadedCid = "";
|
||||
let retrievedCid = "";
|
||||
let uploading = false;
|
||||
let uploadError: string | null = null;
|
||||
let showPreview = false;
|
||||
|
||||
async function uploadContent() {
|
||||
console.log("Upload button clicked");
|
||||
if (!content.trim()) {
|
||||
uploadError = "Content cannot be empty";
|
||||
console.log("Content is empty, not uploading");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Starting upload process");
|
||||
uploading = true;
|
||||
uploadError = null;
|
||||
|
||||
try {
|
||||
console.log(
|
||||
"Calling ipfsService.uploadContent with content:",
|
||||
content.substring(0, 50) + "...",
|
||||
);
|
||||
uploadedCid = await ipfsService.uploadContent(content);
|
||||
console.log("Content uploaded successfully with CID:", uploadedCid);
|
||||
} catch (err: any) {
|
||||
uploadError = err.message || "Failed to upload content";
|
||||
console.error("Upload error:", err);
|
||||
} finally {
|
||||
console.log("Upload process completed, uploading =", uploading);
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetDemo() {
|
||||
content = "";
|
||||
uploadedCid = "";
|
||||
retrievedCid = "";
|
||||
uploadError = null;
|
||||
showPreview = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log("IPFSDemo component mounted");
|
||||
// Check if IPFS is initialized
|
||||
const isInitialized = ipfsService.isInitialized();
|
||||
console.log("IPFS initialized status:", isInitialized);
|
||||
|
||||
if (!isInitialized) {
|
||||
uploadError =
|
||||
"IPFS is not initialized. Please wait or refresh the page.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl mx-auto p-4">
|
||||
<h2 class="text-2xl font-semibold mb-6 text-primary-700">IPFS Demo</h2>
|
||||
|
||||
<div class="bg-background-secondary rounded-lg p-6 mb-8 shadow-sm">
|
||||
<h3 class="text-xl font-medium mb-4 text-primary-600">
|
||||
Upload Content to IPFS
|
||||
</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="content" class="block mb-2 font-medium"
|
||||
>Enter Markdown Content:</label
|
||||
>
|
||||
<textarea
|
||||
id="content"
|
||||
bind:value={content}
|
||||
class="w-full p-3 border border-border rounded-md font-inherit resize-y bg-background-secondary text-text"
|
||||
rows="8"
|
||||
placeholder="# Hello IPFS This is a test of IPFS content storage and retrieval."
|
||||
disabled={uploading}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mb-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
on:click={() => {
|
||||
console.log("Upload button clicked directly");
|
||||
alert("Upload button clicked!");
|
||||
uploadContent();
|
||||
}}
|
||||
disabled={uploading || !content.trim()}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload to IPFS"}
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" on:click={resetDemo}>Reset</Button>
|
||||
</div>
|
||||
|
||||
{#if uploadError}
|
||||
<div
|
||||
class="mt-4 p-3 bg-red-50 border-l-4 border-red-600 text-red-700 rounded"
|
||||
>
|
||||
{uploadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadedCid}
|
||||
<div
|
||||
class="mt-4 p-3 bg-green-50 border-l-4 border-green-600 text-green-800 rounded"
|
||||
>
|
||||
<p>Content uploaded successfully!</p>
|
||||
<p>
|
||||
CID: <code
|
||||
class="bg-black/5 px-2 py-0.5 rounded font-mono break-all"
|
||||
>{uploadedCid}</code
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
on:click={() => {
|
||||
retrievedCid = uploadedCid;
|
||||
showPreview = true;
|
||||
}}
|
||||
>
|
||||
Preview Content
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showPreview && retrievedCid}
|
||||
<div class="bg-background-secondary rounded-lg p-6 mb-8 shadow-sm">
|
||||
<h3 class="text-xl font-medium mb-4 text-primary-600">
|
||||
Content Preview
|
||||
</h3>
|
||||
<p>
|
||||
Retrieving content with CID: <code
|
||||
class="bg-black/5 px-2 py-0.5 rounded font-mono"
|
||||
>{retrievedCid}</code
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="bg-white rounded-md p-4 mt-4 border border-border">
|
||||
<MarkdownContent contentCid={retrievedCid} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
107
sweb/src/components/IPFSProvider.svelte
Normal file
107
sweb/src/components/IPFSProvider.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { ipfsService } from "../services/ipfs.service";
|
||||
|
||||
let initialized = false;
|
||||
let initializing = true;
|
||||
let error: string | null = null;
|
||||
let mockMode = false;
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
initializing = true;
|
||||
initialized = await ipfsService.initialize();
|
||||
console.log("IPFS initialization status:", initialized);
|
||||
|
||||
// Even if initialization returns true, check if IPFS is actually initialized
|
||||
if (!ipfsService.isInitialized()) {
|
||||
console.warn(
|
||||
"IPFS service reports success but is not fully initialized",
|
||||
);
|
||||
mockMode = true;
|
||||
}
|
||||
} catch (err: any) {
|
||||
error = err.message || "Unknown error during IPFS initialization";
|
||||
console.error("Failed to initialize IPFS:", err);
|
||||
mockMode = true;
|
||||
} finally {
|
||||
initializing = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if initializing}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 bg-black/70 text-white py-2 px-4 z-50 flex justify-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="animate-spin"
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
||||
</svg>
|
||||
<span>Initializing IPFS...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 bg-red-600/90 text-white py-2 px-4 z-50 flex justify-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span
|
||||
>IPFS initialization issue: {error} - Running in demo mode</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mockMode}
|
||||
<div
|
||||
class="fixed top-0 left-0 right-0 bg-amber-500/90 text-white py-2 px-4 z-50 flex justify-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
<span>IPFS running in demo mode with mock functionality</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<slot />
|
@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { marked } from "marked";
|
||||
import { ipfsService } from "../services/ipfs.service";
|
||||
|
||||
export let path: string = "";
|
||||
export let contentCid: string = "";
|
||||
|
||||
// Base path for images
|
||||
const imagePath = "/images";
|
||||
@ -11,22 +13,39 @@
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
|
||||
$: if (path) {
|
||||
loadMarkdownContent(path);
|
||||
$: if (path || contentCid) {
|
||||
loadContent();
|
||||
}
|
||||
|
||||
async function loadMarkdownContent(mdPath: string) {
|
||||
if (!mdPath) return;
|
||||
|
||||
async function loadContent() {
|
||||
loading = true;
|
||||
error = null;
|
||||
content = "";
|
||||
|
||||
try {
|
||||
let markdown = "";
|
||||
|
||||
if (contentCid) {
|
||||
// Load from IPFS
|
||||
console.log(
|
||||
`Loading content from IPFS with CID: ${contentCid}`,
|
||||
);
|
||||
try {
|
||||
markdown = await ipfsService.getContent(contentCid);
|
||||
} catch (err: any) {
|
||||
throw new Error(
|
||||
`Failed to load content from IPFS: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Process IPFS image references if any
|
||||
markdown = await processIPFSImageReferences(markdown);
|
||||
} else if (path) {
|
||||
// Load from local file system
|
||||
// Remove leading slash if present
|
||||
const cleanPath = mdPath.startsWith("/")
|
||||
? mdPath.substring(1)
|
||||
: mdPath;
|
||||
const cleanPath = path.startsWith("/")
|
||||
? path.substring(1)
|
||||
: path;
|
||||
|
||||
// If path is just a section like "introduction", append "/introduction" to it
|
||||
const finalPath = cleanPath.includes("/")
|
||||
@ -43,9 +62,12 @@
|
||||
}
|
||||
|
||||
// Get the directory path for relative image references
|
||||
const docDir = finalPath.substring(0, finalPath.lastIndexOf("/"));
|
||||
const docDir = finalPath.substring(
|
||||
0,
|
||||
finalPath.lastIndexOf("/"),
|
||||
);
|
||||
|
||||
let markdown = await response.text();
|
||||
markdown = await response.text();
|
||||
|
||||
// Process markdown to fix image paths
|
||||
// Replace relative image paths with absolute paths
|
||||
@ -55,6 +77,9 @@
|
||||
return ``;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
throw new Error("No path or CID provided");
|
||||
}
|
||||
|
||||
const parsedContent = marked.parse(markdown);
|
||||
content =
|
||||
@ -62,208 +87,154 @@
|
||||
? parsedContent
|
||||
: await parsedContent;
|
||||
} catch (err: any) {
|
||||
console.error("Error loading markdown content:", err);
|
||||
console.error("Error loading content:", err);
|
||||
error = err.message || "Failed to load content";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process IPFS image references in markdown content
|
||||
* @param markdown - Markdown content with IPFS image references
|
||||
* @returns Processed markdown with IPFS images replaced with blob URLs
|
||||
*/
|
||||
async function processIPFSImageReferences(
|
||||
markdown: string,
|
||||
): Promise<string> {
|
||||
// This is a simplified example - actual implementation would depend on how images are referenced
|
||||
// For example, if images are referenced as ipfs://Qm...
|
||||
const ipfsImageRegex = /!\[(.*?)\]\(ipfs:\/\/(.*?)\)/g;
|
||||
|
||||
const promises: Promise<string>[] = [];
|
||||
let match;
|
||||
const replacements: [string, string][] = [];
|
||||
|
||||
while ((match = ipfsImageRegex.exec(markdown)) !== null) {
|
||||
const [fullMatch, alt, cid] = match;
|
||||
|
||||
promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const imageBlob = await ipfsService.getImage(cid);
|
||||
const imageUrl = URL.createObjectURL(imageBlob);
|
||||
replacements.push([
|
||||
fullMatch,
|
||||
``,
|
||||
]);
|
||||
return "success";
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to load image with CID ${cid}:`,
|
||||
error,
|
||||
);
|
||||
return "error";
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
let processedMarkdown = markdown;
|
||||
for (const [original, replacement] of replacements) {
|
||||
processedMarkdown = processedMarkdown.replace(
|
||||
original,
|
||||
replacement,
|
||||
);
|
||||
}
|
||||
|
||||
return processedMarkdown;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (path) {
|
||||
loadMarkdownContent(path);
|
||||
if (path || contentCid) {
|
||||
loadContent();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="markdown-content">
|
||||
<div class="max-w-full">
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<div class="h-screen flex justify-center items-center p-4 sm:p-8">
|
||||
<p>Loading content...</p>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">
|
||||
<div class="text-center p-4 sm:p-8 text-red-500">
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="content">
|
||||
<div class="markdown-content">
|
||||
{@html content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.markdown-content {
|
||||
/* padding: 0.5rem; */
|
||||
max-width: 100%;
|
||||
/* Using @apply for markdown content styling since we need to target elements within the rendered HTML */
|
||||
.markdown-content :global(h1) {
|
||||
@apply text-xl font-bold mb-4 text-primary-700 break-words sm:text-3xl sm:mb-6;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.markdown-content {
|
||||
/* padding: 1rem; */
|
||||
}
|
||||
.markdown-content :global(h2) {
|
||||
@apply text-lg font-semibold mt-6 mb-3 text-primary-800 break-words sm:text-2xl sm:mt-8 sm:mb-4;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
.markdown-content :global(h3) {
|
||||
@apply text-base font-semibold mt-5 mb-2 text-primary-800 break-words sm:text-xl sm:mt-6 sm:mb-3;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.loading,
|
||||
.error {
|
||||
padding: 2rem;
|
||||
}
|
||||
.markdown-content :global(p) {
|
||||
@apply mb-4 leading-relaxed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: rgb(229 62 62);
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ol) {
|
||||
@apply mb-4 ml-6 sm:ml-8;
|
||||
}
|
||||
|
||||
.content :global(h1) {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: rgb(var(--color-primary-700));
|
||||
word-break: break-word;
|
||||
.markdown-content :global(li) {
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.content :global(h2) {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: rgb(var(--color-primary-800));
|
||||
word-break: break-word;
|
||||
.markdown-content :global(a) {
|
||||
@apply text-primary-600 no-underline break-words hover:underline;
|
||||
}
|
||||
|
||||
.content :global(h3) {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgb(var(--color-primary-800));
|
||||
word-break: break-word;
|
||||
.markdown-content :global(blockquote) {
|
||||
@apply border-l-4 border-border pl-4 italic text-text-secondary;
|
||||
}
|
||||
|
||||
.content :global(p) {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
.markdown-content :global(code) {
|
||||
@apply bg-background-secondary py-0.5 px-1.5 rounded text-sm font-mono break-words whitespace-pre-wrap;
|
||||
}
|
||||
|
||||
.content :global(ul),
|
||||
.content :global(ol) {
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 1.5rem;
|
||||
.markdown-content :global(pre) {
|
||||
@apply bg-background-secondary p-3 sm:p-4 rounded-lg overflow-x-auto mb-4;
|
||||
}
|
||||
|
||||
.content :global(li) {
|
||||
margin-bottom: 0.5rem;
|
||||
.markdown-content :global(pre code) {
|
||||
@apply bg-transparent p-0 whitespace-pre break-normal;
|
||||
}
|
||||
|
||||
.content :global(a) {
|
||||
color: rgb(var(--color-primary-600));
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
.markdown-content :global(img) {
|
||||
@apply max-w-full h-auto rounded-lg my-4 block;
|
||||
}
|
||||
|
||||
.content :global(a:hover) {
|
||||
text-decoration: underline;
|
||||
.markdown-content :global(hr) {
|
||||
@apply border-0 border-t border-border my-6;
|
||||
}
|
||||
|
||||
.content :global(blockquote) {
|
||||
border-left: 4px solid rgb(var(--color-border));
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
font-style: italic;
|
||||
color: rgb(var(--color-text-secondary));
|
||||
.markdown-content :global(table) {
|
||||
@apply w-full border-collapse mb-4 block overflow-x-auto;
|
||||
}
|
||||
|
||||
.content :global(code) {
|
||||
background-color: rgb(var(--color-background-secondary));
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.875em;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
.markdown-content :global(th),
|
||||
.markdown-content :global(td) {
|
||||
@apply border border-border p-2 min-w-[100px];
|
||||
}
|
||||
|
||||
.content :global(pre) {
|
||||
background-color: rgb(var(--color-background-secondary));
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content :global(pre code) {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.content :global(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content :global(hr) {
|
||||
border: 0;
|
||||
border-top: 1px solid rgb(var(--color-border));
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.content :global(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.content :global(th),
|
||||
.content :global(td) {
|
||||
border: 1px solid rgb(var(--color-border));
|
||||
padding: 0.5rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.content :global(th) {
|
||||
background-color: rgb(var(--color-background-secondary));
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content :global(h1) {
|
||||
font-size: 2.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content :global(h2) {
|
||||
font-size: 1.75rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.content :global(h3) {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content :global(ul),
|
||||
.content :global(ol) {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
|
||||
.content :global(pre) {
|
||||
padding: 1rem;
|
||||
}
|
||||
.markdown-content :global(th) {
|
||||
@apply bg-background-secondary;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,6 +2,7 @@
|
||||
import type { NavItem } from "../types/nav";
|
||||
import { ChevronRight, ChevronDown } from "lucide-svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
export let item: NavItem;
|
||||
export let level: number = 0;
|
||||
@ -29,27 +30,40 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="nav-item" style="--indent: {indentation}px;">
|
||||
<div class="relative">
|
||||
{#if hasChildren}
|
||||
<!-- Folder item -->
|
||||
<button
|
||||
class="nav-button folder-button {isActive ? 'active' : ''}"
|
||||
class="flex items-center w-full text-left py-2 px-3 pl-[calc({indentation}px+12px)] relative border-none bg-transparent text-text-secondary text-sm cursor-pointer transition-colors duration-200 hover:bg-background/50 {isActive
|
||||
? 'bg-primary-500/10 text-primary-600 font-medium'
|
||||
: ''}"
|
||||
on:click={() => onToggle(item.link)}
|
||||
on:keydown={(e) => handleKeyDown(e, true)}
|
||||
tabindex="0"
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div class="tree-line" style="width: {indentation}px;"></div>
|
||||
<div class="icon-container">
|
||||
<div
|
||||
class="absolute left-0 top-0 bottom-0 pointer-events-none"
|
||||
style="width: {indentation}px;"
|
||||
>
|
||||
<div
|
||||
class="absolute left-3 top-0 bottom-0 w-px bg-border"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center mr-2">
|
||||
<ChevronRight
|
||||
class="chevron-icon {isExpanded ? 'expanded' : ''}"
|
||||
class="w-4 h-4 transition-transform duration-200 {isExpanded
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
/>
|
||||
</div>
|
||||
<span class="label">{item.label}</span>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>{item.label}</span
|
||||
>
|
||||
</button>
|
||||
|
||||
{#if isExpanded && item.children && item.children.length > 0}
|
||||
<div class="children" transition:slide={{ duration: 200 }}>
|
||||
<div class="overflow-hidden" transition:slide={{ duration: 200 }}>
|
||||
{#each item.children as child}
|
||||
<svelte:self
|
||||
item={child}
|
||||
@ -66,94 +80,27 @@
|
||||
<!-- File item -->
|
||||
<a
|
||||
href={item.link}
|
||||
class="nav-button file-button {isActive ? 'active' : ''}"
|
||||
class="flex items-center w-full text-left py-2 px-3 pl-[calc({indentation}px+12px)] relative border-none bg-transparent text-text-secondary text-sm cursor-pointer transition-colors duration-200 hover:bg-background/50 {isActive
|
||||
? 'bg-primary-500/10 text-primary-600 font-medium'
|
||||
: ''}"
|
||||
on:click={(e) => onNavClick(item.link, e)}
|
||||
on:keydown={(e) => handleKeyDown(e, false)}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="tree-line" style="width: {indentation}px;"></div>
|
||||
<div class="icon-container">
|
||||
<div
|
||||
class="absolute left-0 top-0 bottom-0 pointer-events-none"
|
||||
style="width: {indentation}px;"
|
||||
>
|
||||
<div
|
||||
class="absolute left-3 top-0 bottom-0 w-px bg-border"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center mr-2">
|
||||
<!-- No file icon -->
|
||||
</div>
|
||||
<span class="label">{item.label}</span>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>{item.label}</span
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
padding-left: calc(var(--indent) + 12px);
|
||||
position: relative;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
color 0.2s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background-color: rgba(var(--color-background), 0.5);
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background-color: rgba(var(--color-primary-500), 0.1);
|
||||
color: rgb(var(--color-primary-600));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tree-line::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
:global(.chevron-icon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
:global(.chevron-icon.expanded) {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.children {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Menu, Search, X } from "lucide-svelte";
|
||||
import ThemeToggle from "../lib/theme/ThemeToggle.svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
export let toggleSidebar: () => void = () => {};
|
||||
export let isMobile: boolean = false;
|
||||
@ -11,8 +12,10 @@
|
||||
class="bg-background border-b border-border fixed top-0 left-0 right-0 z-30 h-16 flex items-center justify-between px-3 sm:px-4 shadow-sm"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
class="mr-3 p-2 rounded-md hover:bg-background-secondary text-text"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mr-3"
|
||||
on:click={toggleSidebar}
|
||||
aria-label={sidebarVisible ? "Close sidebar" : "Open sidebar"}
|
||||
>
|
||||
@ -21,7 +24,7 @@
|
||||
{:else}
|
||||
<Menu class="h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
</Button>
|
||||
<div class="text-lg sm:text-xl font-bold text-primary-600">
|
||||
SecureWeb
|
||||
</div>
|
||||
@ -29,11 +32,9 @@
|
||||
|
||||
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||
<!-- Search button for mobile -->
|
||||
<button
|
||||
class="md:hidden p-2 rounded-md hover:bg-background-secondary text-text"
|
||||
>
|
||||
<Button variant="ghost" size="icon" class="md:hidden">
|
||||
<Search class="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<!-- Search bar for desktop -->
|
||||
<div class="relative hidden md:block">
|
||||
|
62
sweb/src/components/OfflineStatus.svelte
Normal file
62
sweb/src/components/OfflineStatus.svelte
Normal file
@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { networkService } from "../services/network.service";
|
||||
|
||||
let online = networkService.isOnline();
|
||||
let removeListener: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
removeListener = networkService.addStatusChangeListener((status) => {
|
||||
online = status;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (removeListener) {
|
||||
removeListener();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !online}
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 bg-amber-500 text-white py-2 px-4 flex items-center justify-center gap-2 z-50 shadow-[0_-2px_10px_rgba(0,0,0,0.1)] animate-slide-up"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path>
|
||||
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path>
|
||||
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path>
|
||||
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"></line>
|
||||
</svg>
|
||||
<span>You are offline. Some content may not be available.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
</style>
|
@ -3,6 +3,7 @@
|
||||
import { onMount } from "svelte";
|
||||
import NavItemComponent from "./NavItem.svelte";
|
||||
import { Menu, X } from "lucide-svelte";
|
||||
import Button from "$lib/components/ui/button/button.svelte";
|
||||
|
||||
export let navData: NavItem[] = [];
|
||||
export let onNavItemClick: (path: string) => void = () => {};
|
||||
@ -75,10 +76,11 @@
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="sidebar bg-background-secondary border-border h-screen fixed top-0 left-0 pt-16 overflow-y-auto shadow-sm z-20
|
||||
class="bg-background-secondary border-border h-screen fixed top-0 left-0 pt-16 overflow-y-auto shadow-sm z-20
|
||||
{isMobile ? 'w-[85%] max-w-xs' : 'w-64'}
|
||||
transition-transform duration-300 ease-in-out
|
||||
{visible ? 'translate-x-0' : '-translate-x-full'}"
|
||||
{visible ? 'translate-x-0' : '-translate-x-full'}
|
||||
scrollbar-thin scrollbar-thumb-gray-400/50 scrollbar-track-transparent border-r border-border"
|
||||
>
|
||||
<nav class="w-full py-2">
|
||||
{#each navData as item}
|
||||
@ -95,32 +97,13 @@
|
||||
</aside>
|
||||
|
||||
{#if isMobile && !visible}
|
||||
<button
|
||||
class="fixed top-4 left-4 z-10 p-2 bg-background-secondary rounded-md shadow-md hover:bg-background"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class="fixed top-4 left-4 z-10 shadow-md"
|
||||
on:click={toggleSidebar}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<Menu class="w-5 h-5 text-text" />
|
||||
</button>
|
||||
<Menu class="w-5 h-5" />
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
border-right: 1px solid rgb(var(--color-border));
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
|
309
sweb/src/services/ipfs-cache.service.ts
Normal file
309
sweb/src/services/ipfs-cache.service.ts
Normal file
@ -0,0 +1,309 @@
|
||||
import { openDB } from 'idb'
|
||||
import type { DBSchema, IDBPDatabase } from 'idb'
|
||||
|
||||
/**
|
||||
* Interface for the IPFS cache database schema
|
||||
*/
|
||||
interface IPFSCacheDB extends DBSchema {
|
||||
'ipfs-content': {
|
||||
key: string;
|
||||
value: {
|
||||
cid: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
contentType: string;
|
||||
};
|
||||
indexes: { 'by-timestamp': number };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for caching IPFS content locally using IndexedDB
|
||||
* Provides methods for storing and retrieving content from the cache
|
||||
*/
|
||||
class IPFSCacheService {
|
||||
private db: IDBPDatabase<IPFSCacheDB> | null = null
|
||||
private readonly DB_NAME = 'ipfs-cache'
|
||||
private readonly STORE_NAME = 'ipfs-content'
|
||||
private readonly MAX_CACHE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
private readonly MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
/**
|
||||
* Initialize the cache database
|
||||
* @returns Promise<boolean> - True if initialization was successful
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
this.db = await openDB<IPFSCacheDB>(this.DB_NAME, 1, {
|
||||
upgrade(db) {
|
||||
const store = db.createObjectStore('ipfs-content', { keyPath: 'cid' })
|
||||
store.createIndex('by-timestamp', 'timestamp')
|
||||
}
|
||||
})
|
||||
|
||||
// Clean up old cache entries on initialization
|
||||
await this.cleanupCache()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize IPFS cache:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache text content
|
||||
* @param cid - Content identifier
|
||||
* @param content - Content to cache
|
||||
* @param contentType - MIME type of the content
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async cacheContent(cid: string, content: string, contentType: string = 'text/plain'): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.db.put(this.STORE_NAME, {
|
||||
cid,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
contentType
|
||||
})
|
||||
|
||||
// Check cache size and clean up if necessary
|
||||
await this.checkCacheSize()
|
||||
} catch (error) {
|
||||
console.error(`Failed to cache content for CID ${cid}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache binary content as a base64 string
|
||||
* @param cid - Content identifier
|
||||
* @param blob - Binary content to cache
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async cacheBlob(cid: string, blob: Blob): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert blob to base64 string for storage
|
||||
const reader = new FileReader()
|
||||
const contentPromise = new Promise<string>((resolve, reject) => {
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
})
|
||||
|
||||
reader.readAsDataURL(blob)
|
||||
const content = await contentPromise
|
||||
|
||||
await this.db.put(this.STORE_NAME, {
|
||||
cid,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
contentType: blob.type || 'application/octet-stream'
|
||||
})
|
||||
|
||||
// Check cache size and clean up if necessary
|
||||
await this.checkCacheSize()
|
||||
} catch (error) {
|
||||
console.error(`Failed to cache blob for CID ${cid}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content from cache
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<string | null> - Cached content or null if not found
|
||||
*/
|
||||
async getContent(cid: string): Promise<string | null> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await this.db.get(this.STORE_NAME, cid)
|
||||
|
||||
if (!entry || entry.contentType !== 'text/plain') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Update timestamp to mark as recently used
|
||||
await this.db.put(this.STORE_NAME, {
|
||||
...entry,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return entry.content
|
||||
} catch (error) {
|
||||
console.error(`Failed to get cached content for CID ${cid}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get binary content from cache
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<Blob | null> - Cached content as Blob or null if not found
|
||||
*/
|
||||
async getBlob(cid: string): Promise<Blob | null> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await this.db.get(this.STORE_NAME, cid)
|
||||
|
||||
if (!entry || entry.contentType === 'text/plain') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Update timestamp to mark as recently used
|
||||
await this.db.put(this.STORE_NAME, {
|
||||
...entry,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Convert base64 string back to blob
|
||||
const response = await fetch(entry.content)
|
||||
return await response.blob()
|
||||
} catch (error) {
|
||||
console.error(`Failed to get cached blob for CID ${cid}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is in cache
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<boolean> - True if content is in cache
|
||||
*/
|
||||
async hasCached(cid: string): Promise<boolean> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await this.db.get(this.STORE_NAME, cid)
|
||||
return !!entry
|
||||
} catch (error) {
|
||||
console.error(`Failed to check cache for CID ${cid}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove content from cache
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async removeFromCache(cid: string): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.db.delete(this.STORE_NAME, cid)
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove CID ${cid} from cache:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the entire cache
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
if (!this.db) {
|
||||
throw new Error('Cache not initialized')
|
||||
}
|
||||
|
||||
try {
|
||||
await this.db.clear(this.STORE_NAME)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear cache:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
private async cleanupCache(): Promise<void> {
|
||||
if (!this.db) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const now = Date.now()
|
||||
const expiredTimestamp = now - this.MAX_CACHE_AGE
|
||||
|
||||
// Get all entries older than MAX_CACHE_AGE
|
||||
const tx = this.db.transaction(this.STORE_NAME, 'readwrite')
|
||||
const index = tx.store.index('by-timestamp')
|
||||
let cursor = await index.openCursor(IDBKeyRange.upperBound(expiredTimestamp))
|
||||
|
||||
// Delete expired entries
|
||||
while (cursor) {
|
||||
await cursor.delete()
|
||||
cursor = await cursor.continue()
|
||||
}
|
||||
|
||||
await tx.done
|
||||
} catch (error) {
|
||||
console.error('Failed to clean up cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check cache size and remove oldest entries if necessary
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
private async checkCacheSize(): Promise<void> {
|
||||
if (!this.db) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all entries
|
||||
const entries = await this.db.getAll(this.STORE_NAME)
|
||||
|
||||
// Calculate total size
|
||||
let totalSize = 0
|
||||
for (const entry of entries) {
|
||||
totalSize += entry.content.length
|
||||
}
|
||||
|
||||
// If cache is too large, remove oldest entries
|
||||
if (totalSize > this.MAX_CACHE_SIZE) {
|
||||
// Sort by timestamp (oldest first)
|
||||
entries.sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// Remove oldest entries until we're under the limit
|
||||
const tx = this.db.transaction(this.STORE_NAME, 'readwrite')
|
||||
for (const entry of entries) {
|
||||
await tx.store.delete(entry.cid)
|
||||
totalSize -= entry.content.length
|
||||
|
||||
if (totalSize <= this.MAX_CACHE_SIZE * 0.8) { // Reduce to 80% of max
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await tx.done
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check cache size:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const ipfsCacheService = new IPFSCacheService()
|
57
sweb/src/services/ipfs-gateway.service.ts
Normal file
57
sweb/src/services/ipfs-gateway.service.ts
Normal file
@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Service for fetching IPFS content through public gateways
|
||||
* Provides fallback when direct IPFS connections fail
|
||||
*/
|
||||
class IPFSGatewayService {
|
||||
// List of public IPFS gateways to try
|
||||
private gateways = [
|
||||
'https://ipfs.io/ipfs/',
|
||||
'https://gateway.pinata.cloud/ipfs/',
|
||||
'https://cloudflare-ipfs.com/ipfs/',
|
||||
'https://dweb.link/ipfs/'
|
||||
]
|
||||
|
||||
/**
|
||||
* Fetch content from a gateway by trying each gateway in order until one succeeds
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<Response> - Response from the gateway
|
||||
*/
|
||||
async fetchFromGateway(cid: string): Promise<Response> {
|
||||
// Try gateways in order until one succeeds
|
||||
for (const gateway of this.gateways) {
|
||||
try {
|
||||
const response = await fetch(`${gateway}${cid}`)
|
||||
if (response.ok) {
|
||||
return response
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Gateway ${gateway} failed for CID ${cid}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`All gateways failed for CID ${cid}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content as text from a gateway
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<string> - Content as string
|
||||
*/
|
||||
async getContent(cid: string): Promise<string> {
|
||||
const response = await this.fetchFromGateway(cid)
|
||||
return await response.text()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content as blob from a gateway
|
||||
* @param cid - Content identifier
|
||||
* @returns Promise<Blob> - Content as blob
|
||||
*/
|
||||
async getImage(cid: string): Promise<Blob> {
|
||||
const response = await this.fetchFromGateway(cid)
|
||||
return await response.blob()
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const ipfsGatewayService = new IPFSGatewayService()
|
324
sweb/src/services/ipfs.service.ts
Normal file
324
sweb/src/services/ipfs.service.ts
Normal file
@ -0,0 +1,324 @@
|
||||
import { createHelia } from 'helia'
|
||||
import { unixfs } from '@helia/unixfs'
|
||||
import type { Helia } from '@helia/interface'
|
||||
import type { UnixFS } from '@helia/unixfs'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import { createLibp2p } from 'libp2p'
|
||||
import { webSockets } from '@libp2p/websockets'
|
||||
import { webRTC } from '@libp2p/webrtc'
|
||||
import { webTransport } from '@libp2p/webtransport'
|
||||
import { ipfsGatewayService } from './ipfs-gateway.service'
|
||||
import { ipfsCacheService } from './ipfs-cache.service'
|
||||
import { networkService } from './network.service'
|
||||
|
||||
/**
|
||||
* Service for interacting with IPFS using Helia
|
||||
* Provides methods for initializing IPFS, retrieving content, and uploading content
|
||||
*/
|
||||
class IPFSService {
|
||||
private helia: Helia | null = null
|
||||
private fs: UnixFS | null = null
|
||||
|
||||
/**
|
||||
* Initialize the IPFS client
|
||||
* @returns Promise<boolean> - True if initialization was successful
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
// Initialize cache first
|
||||
await ipfsCacheService.initialize()
|
||||
|
||||
console.log('Initializing IPFS with simplified configuration...')
|
||||
|
||||
// Create a Helia instance with minimal configuration
|
||||
this.helia = await createHelia({
|
||||
// Use minimal configuration for browser environment
|
||||
})
|
||||
|
||||
console.log('Helia instance created successfully')
|
||||
|
||||
// Create a UnixFS instance for file operations
|
||||
if (this.helia) {
|
||||
this.fs = unixfs(this.helia)
|
||||
console.log('UnixFS instance created successfully')
|
||||
}
|
||||
|
||||
console.log('IPFS initialized successfully')
|
||||
return true
|
||||
} catch (error) {
|
||||
// More detailed error logging
|
||||
console.error('Failed to initialize IPFS:', error)
|
||||
if (error instanceof Error) {
|
||||
console.error('Error message:', error.message)
|
||||
console.error('Error stack:', error.stack)
|
||||
}
|
||||
|
||||
// For demo purposes, return true to allow the app to function
|
||||
// In a production environment, you would handle this differently
|
||||
console.warn('Continuing with IPFS in mock mode for demo purposes')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content from IPFS by CID
|
||||
* @param cidStr - Content identifier as string
|
||||
* @returns Promise<string> - Content as string
|
||||
*/
|
||||
async getContent(cidStr: string): Promise<string> {
|
||||
// Check cache first
|
||||
try {
|
||||
const cachedContent = await ipfsCacheService.getContent(cidStr)
|
||||
if (cachedContent) {
|
||||
console.log(`Retrieved content for CID ${cidStr} from cache`)
|
||||
return cachedContent
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Cache retrieval failed for CID ${cidStr}:`, error)
|
||||
}
|
||||
|
||||
// If offline and not in cache, throw error
|
||||
if (!networkService.isOnline()) {
|
||||
throw new Error('Cannot retrieve content: You are offline and the content is not available in the cache')
|
||||
}
|
||||
|
||||
// For demo purposes, if IPFS is not initialized, use mock data
|
||||
if (!this.fs) {
|
||||
console.warn('IPFS not initialized, using mock data for demo')
|
||||
return `# Mock IPFS Content\n\nThis is mock content for CID: ${cidStr}\n\nIPFS is not fully initialized, but the demo can still show the basic functionality.`
|
||||
}
|
||||
|
||||
try {
|
||||
const cid = CID.parse(cidStr)
|
||||
// Fetch content from IPFS
|
||||
const decoder = new TextDecoder()
|
||||
let content = ''
|
||||
|
||||
for await (const chunk of this.fs.cat(cid)) {
|
||||
content += decoder.decode(chunk, { stream: true })
|
||||
}
|
||||
|
||||
// Cache the content
|
||||
try {
|
||||
await ipfsCacheService.cacheContent(cidStr, content, 'text/plain')
|
||||
} catch (cacheError) {
|
||||
console.warn(`Failed to cache content for CID ${cidStr}:`, cacheError)
|
||||
}
|
||||
|
||||
return content
|
||||
} catch (error) {
|
||||
console.warn(`Direct IPFS retrieval failed for CID ${cidStr}, trying gateways:`, error)
|
||||
|
||||
try {
|
||||
// Try gateway fallback
|
||||
const content = await ipfsGatewayService.getContent(cidStr)
|
||||
|
||||
// Cache the content
|
||||
try {
|
||||
await ipfsCacheService.cacheContent(cidStr, content, 'text/plain')
|
||||
} catch (cacheError) {
|
||||
console.warn(`Failed to cache content from gateway for CID ${cidStr}:`, cacheError)
|
||||
}
|
||||
|
||||
return content
|
||||
} catch (error: any) {
|
||||
const gatewayError = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(`Gateway fallback also failed for CID ${cidStr}:`, gatewayError)
|
||||
|
||||
// For demo purposes, return mock data
|
||||
return `# Mock IPFS Content\n\nThis is mock content for CID: ${cidStr}\n\nFailed to retrieve actual content, but the demo can still show the basic functionality.`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image data from IPFS by CID
|
||||
* @param cidStr - Content identifier as string
|
||||
* @returns Promise<Blob> - Image as Blob
|
||||
*/
|
||||
async getImage(cidStr: string): Promise<Blob> {
|
||||
// Check cache first
|
||||
try {
|
||||
const cachedBlob = await ipfsCacheService.getBlob(cidStr)
|
||||
if (cachedBlob) {
|
||||
console.log(`Retrieved image for CID ${cidStr} from cache`)
|
||||
return cachedBlob
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Cache retrieval failed for CID ${cidStr}:`, error)
|
||||
}
|
||||
|
||||
// If offline and not in cache, throw error
|
||||
if (!networkService.isOnline()) {
|
||||
throw new Error('Cannot retrieve image: You are offline and the image is not available in the cache')
|
||||
}
|
||||
|
||||
// For demo purposes, if IPFS is not initialized, use a placeholder image
|
||||
if (!this.fs) {
|
||||
console.warn('IPFS not initialized, using placeholder image for demo')
|
||||
// Create a simple SVG as a placeholder
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="#f0f0f0"/>
|
||||
<text x="50%" y="50%" font-family="Arial" font-size="16" text-anchor="middle">Placeholder Image</text>
|
||||
<text x="50%" y="70%" font-family="Arial" font-size="12" text-anchor="middle">CID: ${cidStr}</text>
|
||||
</svg>`;
|
||||
return new Blob([svgContent], { type: 'image/svg+xml' });
|
||||
}
|
||||
|
||||
try {
|
||||
const cid = CID.parse(cidStr)
|
||||
// Fetch image data from IPFS
|
||||
const chunks: Uint8Array[] = []
|
||||
|
||||
for await (const chunk of this.fs.cat(cid)) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Combine chunks into a single Uint8Array
|
||||
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
|
||||
const allChunks = new Uint8Array(totalLength)
|
||||
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
allChunks.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
// Create a Blob from the Uint8Array
|
||||
const blob = new Blob([allChunks])
|
||||
|
||||
// Cache the blob
|
||||
try {
|
||||
await ipfsCacheService.cacheBlob(cidStr, blob)
|
||||
} catch (cacheError) {
|
||||
console.warn(`Failed to cache image for CID ${cidStr}:`, cacheError)
|
||||
}
|
||||
|
||||
return blob
|
||||
} catch (error) {
|
||||
console.warn(`Direct IPFS image retrieval failed for CID ${cidStr}, trying gateways:`, error)
|
||||
|
||||
try {
|
||||
// Try gateway fallback
|
||||
const blob = await ipfsGatewayService.getImage(cidStr)
|
||||
|
||||
// Cache the blob
|
||||
try {
|
||||
await ipfsCacheService.cacheBlob(cidStr, blob)
|
||||
} catch (cacheError) {
|
||||
console.warn(`Failed to cache image from gateway for CID ${cidStr}:`, cacheError)
|
||||
}
|
||||
|
||||
return blob
|
||||
} catch (error: any) {
|
||||
const gatewayError = error instanceof Error ? error : new Error(String(error))
|
||||
console.error(`Gateway fallback also failed for CID ${cidStr}:`, gatewayError)
|
||||
|
||||
// For demo purposes, return a placeholder image
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200">
|
||||
<rect width="200" height="200" fill="#ffeeee"/>
|
||||
<text x="50%" y="50%" font-family="Arial" font-size="16" text-anchor="middle" fill="red">Image Not Found</text>
|
||||
<text x="50%" y="70%" font-family="Arial" font-size="12" text-anchor="middle">CID: ${cidStr}</text>
|
||||
</svg>`;
|
||||
return new Blob([svgContent], { type: 'image/svg+xml' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload content to IPFS
|
||||
* @param content - Content as string
|
||||
* @returns Promise<string> - CID of the uploaded content
|
||||
*/
|
||||
async uploadContent(content: string): Promise<string> {
|
||||
if (!this.fs) {
|
||||
console.warn('IPFS not initialized, using mock CID for demo')
|
||||
// Generate a mock CID for demo purposes
|
||||
const mockCid = `mock-cid-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 7)}`;
|
||||
|
||||
// Cache the content with the mock CID
|
||||
try {
|
||||
await ipfsCacheService.cacheContent(mockCid, content, 'text/plain')
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache mock content:', error)
|
||||
}
|
||||
|
||||
return mockCid;
|
||||
}
|
||||
|
||||
try {
|
||||
const encoder = new TextEncoder()
|
||||
const cid = await this.fs.addBytes(encoder.encode(content))
|
||||
return cid.toString()
|
||||
} catch (error) {
|
||||
console.error('Failed to upload content:', error)
|
||||
|
||||
// For demo purposes, return a mock CID
|
||||
const mockCid = `mock-cid-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 7)}`;
|
||||
|
||||
// Cache the content with the mock CID
|
||||
try {
|
||||
await ipfsCacheService.cacheContent(mockCid, content, 'text/plain')
|
||||
} catch (cacheError) {
|
||||
console.warn('Failed to cache mock content:', cacheError)
|
||||
}
|
||||
|
||||
return mockCid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to IPFS
|
||||
* @param file - File to upload
|
||||
* @returns Promise<string> - CID of the uploaded file
|
||||
*/
|
||||
async uploadFile(file: File): Promise<string> {
|
||||
if (!this.fs) {
|
||||
console.warn('IPFS not initialized, using mock CID for demo')
|
||||
// Generate a mock CID for demo purposes
|
||||
const mockCid = `mock-cid-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 7)}`;
|
||||
|
||||
// Cache the file with the mock CID
|
||||
try {
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: file.type });
|
||||
await ipfsCacheService.cacheBlob(mockCid, blob)
|
||||
} catch (error) {
|
||||
console.warn('Failed to cache mock file:', error)
|
||||
}
|
||||
|
||||
return mockCid;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const cid = await this.fs.addBytes(new Uint8Array(buffer))
|
||||
return cid.toString()
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file:', error)
|
||||
|
||||
// For demo purposes, return a mock CID
|
||||
const mockCid = `mock-cid-${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 7)}`;
|
||||
|
||||
// Cache the file with the mock CID
|
||||
try {
|
||||
const blob = new Blob([await file.arrayBuffer()], { type: file.type });
|
||||
await ipfsCacheService.cacheBlob(mockCid, blob)
|
||||
} catch (cacheError) {
|
||||
console.warn('Failed to cache mock file:', cacheError)
|
||||
}
|
||||
|
||||
return mockCid;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IPFS is initialized
|
||||
* @returns boolean - True if IPFS is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.helia !== null && this.fs !== null
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const ipfsService = new IPFSService()
|
69
sweb/src/services/network.service.ts
Normal file
69
sweb/src/services/network.service.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Service for monitoring network status
|
||||
* Provides methods to check if the user is online and to register listeners for network status changes
|
||||
*/
|
||||
class NetworkService {
|
||||
private online: boolean = navigator.onLine
|
||||
private listeners: Set<(online: boolean) => void> = new Set()
|
||||
|
||||
constructor() {
|
||||
// Initialize with current online status
|
||||
this.online = navigator.onLine
|
||||
|
||||
// Add event listeners for online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline.bind(this))
|
||||
window.addEventListener('offline', this.handleOffline.bind(this))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is currently online
|
||||
* @returns boolean - True if online, false if offline
|
||||
*/
|
||||
isOnline(): boolean {
|
||||
return this.online
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a listener for network status changes
|
||||
* @param listener - Function to call when network status changes
|
||||
* @returns Function - Function to remove the listener
|
||||
*/
|
||||
addStatusChangeListener(listener: (online: boolean) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
|
||||
// Return function to remove listener
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle online event
|
||||
*/
|
||||
private handleOnline(): void {
|
||||
this.online = true
|
||||
this.notifyListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle offline event
|
||||
*/
|
||||
private handleOffline(): void {
|
||||
this.online = false
|
||||
this.notifyListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of network status change
|
||||
*/
|
||||
private notifyListeners(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this.online)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const networkService = new NetworkService()
|
Loading…
Reference in New Issue
Block a user