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:
Mahmoud Emad 2025-05-13 09:31:14 +03:00
parent f22e9faae2
commit 3e1822247d
16 changed files with 4132 additions and 454 deletions

1446
ipfs-implementation-plan.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -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>
<IPFSProvider>
<Layout>
<Home />
<Home contentPath="" />
</Layout>
<OfflineStatus />
</IPFSProvider>

View File

@ -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>

View 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&#10;&#10;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>

View 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 />

View File

@ -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 `![${alt}](/images/${docDir}/${imgPath})`;
},
);
} 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,
`![${alt}](${imageUrl})`,
]);
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>

View File

@ -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>

View File

@ -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">

View 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>

View File

@ -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>

View 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()

View 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()

View 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()

View 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()