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"
|
"vite": "^6.3.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/postcss": "^4.1.6",
|
||||||
"@tailwindcss/vite": "^4.1.6",
|
"@tailwindcss/vite": "^4.1.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"helia": "^5.3.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
|
"libp2p": "^2.8.5",
|
||||||
"lucide-svelte": "^0.509.0",
|
"lucide-svelte": "^0.509.0",
|
||||||
"marked": "^15.0.11",
|
"marked": "^15.0.11",
|
||||||
|
"multiformats": "^13.3.3",
|
||||||
"shadcn-svelte": "^0.14.2",
|
"shadcn-svelte": "^0.14.2",
|
||||||
"tailwind-merge": "^3.2.0"
|
"tailwind-merge": "^3.2.0"
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Layout from "./components/Layout.svelte";
|
import Layout from "./components/Layout.svelte";
|
||||||
import Home from "./components/Home.svelte";
|
import Home from "./components/Home.svelte";
|
||||||
|
import OfflineStatus from "./components/OfflineStatus.svelte";
|
||||||
|
import * as IPFSProviderModule from "./components/IPFSProvider.svelte";
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
|
// Use the component from the module
|
||||||
|
const IPFSProvider = IPFSProviderModule.default;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout>
|
<IPFSProvider>
|
||||||
<Home />
|
<Layout>
|
||||||
</Layout>
|
<Home contentPath="" />
|
||||||
|
</Layout>
|
||||||
|
<OfflineStatus />
|
||||||
|
</IPFSProvider>
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MarkdownContent from "./MarkdownContent.svelte";
|
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";
|
export let contentPath: string = "introduction/introduction";
|
||||||
|
|
||||||
// Default to introduction if no path is provided
|
// Default to introduction if no path is provided
|
||||||
$: actualPath = contentPath || "introduction/introduction";
|
$: actualPath = contentPath || "introduction/introduction";
|
||||||
|
|
||||||
|
// Flag to show IPFS demo
|
||||||
|
let showIPFSDemo = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="">
|
<div class="">
|
||||||
@ -16,162 +20,35 @@
|
|||||||
<MarkdownContent path={actualPath} />
|
<MarkdownContent path={actualPath} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Hero Section -->
|
<!-- IPFS Demo 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 -->
|
|
||||||
<section class="mb-12 sm:mb-16 md:mb-20">
|
<section class="mb-12 sm:mb-16 md:mb-20">
|
||||||
<div class="text-center mb-8 sm:mb-12">
|
<div class="text-center mb-8 sm:mb-12">
|
||||||
<h2
|
<h2
|
||||||
class="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4 text-text"
|
class="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4 text-text"
|
||||||
>
|
>
|
||||||
Our Features
|
IPFS Integration
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
class="text-lg sm:text-xl text-text-secondary max-w-3xl mx-auto"
|
class="text-lg sm:text-xl text-text-secondary max-w-3xl mx-auto"
|
||||||
>
|
>
|
||||||
Discover what makes SecureWeb the preferred choice for
|
Experience the power of decentralized content storage and
|
||||||
security-conscious businesses.
|
retrieval with IPFS.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div class="mt-6">
|
||||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 sm:gap-8 md:gap-10"
|
<Button
|
||||||
>
|
variant="primary"
|
||||||
<div
|
size="lg"
|
||||||
class="bg-background p-6 sm:p-8 rounded-xl shadow-md hover:shadow-lg transition-shadow border border-border"
|
on:click={() => (showIPFSDemo = !showIPFSDemo)}
|
||||||
>
|
|
||||||
<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
|
{showIPFSDemo ? "Hide IPFS Demo" : "Show IPFS Demo"}
|
||||||
class="h-6 w-6 sm:h-7 sm:w-7 text-primary-600"
|
</Button>
|
||||||
/>
|
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CTA Section -->
|
{#if showIPFSDemo}
|
||||||
<section>
|
<IPFSDemo />
|
||||||
<div
|
{/if}
|
||||||
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>
|
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
import { ipfsService } from "../services/ipfs.service";
|
||||||
|
|
||||||
export let path: string = "";
|
export let path: string = "";
|
||||||
|
export let contentCid: string = "";
|
||||||
|
|
||||||
// Base path for images
|
// Base path for images
|
||||||
const imagePath = "/images";
|
const imagePath = "/images";
|
||||||
@ -11,259 +13,228 @@
|
|||||||
let loading = true;
|
let loading = true;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
|
|
||||||
$: if (path) {
|
$: if (path || contentCid) {
|
||||||
loadMarkdownContent(path);
|
loadContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMarkdownContent(mdPath: string) {
|
async function loadContent() {
|
||||||
if (!mdPath) return;
|
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
content = "";
|
content = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Remove leading slash if present
|
let markdown = "";
|
||||||
const cleanPath = mdPath.startsWith("/")
|
|
||||||
? mdPath.substring(1)
|
|
||||||
: mdPath;
|
|
||||||
|
|
||||||
// If path is just a section like "introduction", append "/introduction" to it
|
if (contentCid) {
|
||||||
const finalPath = cleanPath.includes("/")
|
// Load from IPFS
|
||||||
? cleanPath
|
console.log(
|
||||||
: `${cleanPath}/${cleanPath}`;
|
`Loading content from IPFS with CID: ${contentCid}`,
|
||||||
|
|
||||||
console.log(`Loading markdown from: /src/docs/${finalPath}.md`);
|
|
||||||
|
|
||||||
const response = await fetch(`/src/docs/${finalPath}.md`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to load content: ${response.status} ${response.statusText}`,
|
|
||||||
);
|
);
|
||||||
|
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 = path.startsWith("/")
|
||||||
|
? path.substring(1)
|
||||||
|
: path;
|
||||||
|
|
||||||
|
// If path is just a section like "introduction", append "/introduction" to it
|
||||||
|
const finalPath = cleanPath.includes("/")
|
||||||
|
? cleanPath
|
||||||
|
: `${cleanPath}/${cleanPath}`;
|
||||||
|
|
||||||
|
console.log(`Loading markdown from: /src/docs/${finalPath}.md`);
|
||||||
|
|
||||||
|
const response = await fetch(`/src/docs/${finalPath}.md`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load content: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the directory path for relative image references
|
||||||
|
const docDir = finalPath.substring(
|
||||||
|
0,
|
||||||
|
finalPath.lastIndexOf("/"),
|
||||||
|
);
|
||||||
|
|
||||||
|
markdown = await response.text();
|
||||||
|
|
||||||
|
// Process markdown to fix image paths
|
||||||
|
// Replace relative image paths with absolute paths
|
||||||
|
markdown = markdown.replace(
|
||||||
|
/!\[(.*?)\]\((?!http|\/)(.*?)\)/g,
|
||||||
|
(_match, alt, imgPath) => {
|
||||||
|
return ``;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error("No path or CID provided");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the directory path for relative image references
|
|
||||||
const docDir = finalPath.substring(0, finalPath.lastIndexOf("/"));
|
|
||||||
|
|
||||||
let markdown = await response.text();
|
|
||||||
|
|
||||||
// Process markdown to fix image paths
|
|
||||||
// Replace relative image paths with absolute paths
|
|
||||||
markdown = markdown.replace(
|
|
||||||
/!\[(.*?)\]\((?!http|\/)(.*?)\)/g,
|
|
||||||
(_match, alt, imgPath) => {
|
|
||||||
return ``;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsedContent = marked.parse(markdown);
|
const parsedContent = marked.parse(markdown);
|
||||||
content =
|
content =
|
||||||
typeof parsedContent === "string"
|
typeof parsedContent === "string"
|
||||||
? parsedContent
|
? parsedContent
|
||||||
: await parsedContent;
|
: await parsedContent;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Error loading markdown content:", err);
|
console.error("Error loading content:", err);
|
||||||
error = err.message || "Failed to load content";
|
error = err.message || "Failed to load content";
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
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(() => {
|
onMount(() => {
|
||||||
if (path) {
|
if (path || contentCid) {
|
||||||
loadMarkdownContent(path);
|
loadContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="markdown-content">
|
<div class="max-w-full">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">
|
<div class="h-screen flex justify-center items-center p-4 sm:p-8">
|
||||||
<p>Loading content...</p>
|
<p>Loading content...</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="error">
|
<div class="text-center p-4 sm:p-8 text-red-500">
|
||||||
<p>Error: {error}</p>
|
<p>Error: {error}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="content">
|
<div class="markdown-content">
|
||||||
{@html content}
|
{@html content}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.markdown-content {
|
/* Using @apply for markdown content styling since we need to target elements within the rendered HTML */
|
||||||
/* padding: 0.5rem; */
|
.markdown-content :global(h1) {
|
||||||
max-width: 100%;
|
@apply text-xl font-bold mb-4 text-primary-700 break-words sm:text-3xl sm:mb-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
.markdown-content :global(h2) {
|
||||||
.markdown-content {
|
@apply text-lg font-semibold mt-6 mb-3 text-primary-800 break-words sm:text-2xl sm:mt-8 sm:mb-4;
|
||||||
/* padding: 1rem; */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading,
|
.markdown-content :global(h3) {
|
||||||
.error {
|
@apply text-base font-semibold mt-5 mb-2 text-primary-800 break-words sm:text-xl sm:mt-6 sm:mb-3;
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
.markdown-content :global(p) {
|
||||||
.loading,
|
@apply mb-4 leading-relaxed;
|
||||||
.error {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.markdown-content :global(ul),
|
||||||
color: rgb(229 62 62);
|
.markdown-content :global(ol) {
|
||||||
|
@apply mb-4 ml-6 sm:ml-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(h1) {
|
.markdown-content :global(li) {
|
||||||
font-size: 1.75rem;
|
@apply mb-2;
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: rgb(var(--color-primary-700));
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(h2) {
|
.markdown-content :global(a) {
|
||||||
font-size: 1.5rem;
|
@apply text-primary-600 no-underline break-words hover:underline;
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
color: rgb(var(--color-primary-800));
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(h3) {
|
.markdown-content :global(blockquote) {
|
||||||
font-size: 1.25rem;
|
@apply border-l-4 border-border pl-4 italic text-text-secondary;
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1.25rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: rgb(var(--color-primary-800));
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(p) {
|
.markdown-content :global(code) {
|
||||||
margin-bottom: 1rem;
|
@apply bg-background-secondary py-0.5 px-1.5 rounded text-sm font-mono break-words whitespace-pre-wrap;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(ul),
|
.markdown-content :global(pre) {
|
||||||
.content :global(ol) {
|
@apply bg-background-secondary p-3 sm:p-4 rounded-lg overflow-x-auto mb-4;
|
||||||
margin-bottom: 1rem;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(li) {
|
.markdown-content :global(pre code) {
|
||||||
margin-bottom: 0.5rem;
|
@apply bg-transparent p-0 whitespace-pre break-normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(a) {
|
.markdown-content :global(img) {
|
||||||
color: rgb(var(--color-primary-600));
|
@apply max-w-full h-auto rounded-lg my-4 block;
|
||||||
text-decoration: none;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(a:hover) {
|
.markdown-content :global(hr) {
|
||||||
text-decoration: underline;
|
@apply border-0 border-t border-border my-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(blockquote) {
|
.markdown-content :global(table) {
|
||||||
border-left: 4px solid rgb(var(--color-border));
|
@apply w-full border-collapse mb-4 block overflow-x-auto;
|
||||||
padding-left: 1rem;
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
font-style: italic;
|
|
||||||
color: rgb(var(--color-text-secondary));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(code) {
|
.markdown-content :global(th),
|
||||||
background-color: rgb(var(--color-background-secondary));
|
.markdown-content :global(td) {
|
||||||
padding: 0.2rem 0.4rem;
|
@apply border border-border p-2 min-w-[100px];
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.875em;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content :global(pre) {
|
.markdown-content :global(th) {
|
||||||
background-color: rgb(var(--color-background-secondary));
|
@apply bg-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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import type { NavItem } from "../types/nav";
|
import type { NavItem } from "../types/nav";
|
||||||
import { ChevronRight, ChevronDown } from "lucide-svelte";
|
import { ChevronRight, ChevronDown } from "lucide-svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
|
|
||||||
export let item: NavItem;
|
export let item: NavItem;
|
||||||
export let level: number = 0;
|
export let level: number = 0;
|
||||||
@ -29,27 +30,40 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="nav-item" style="--indent: {indentation}px;">
|
<div class="relative">
|
||||||
{#if hasChildren}
|
{#if hasChildren}
|
||||||
<!-- Folder item -->
|
<!-- Folder item -->
|
||||||
<button
|
<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:click={() => onToggle(item.link)}
|
||||||
on:keydown={(e) => handleKeyDown(e, true)}
|
on:keydown={(e) => handleKeyDown(e, true)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
>
|
>
|
||||||
<div class="tree-line" style="width: {indentation}px;"></div>
|
<div
|
||||||
<div class="icon-container">
|
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
|
<ChevronRight
|
||||||
class="chevron-icon {isExpanded ? 'expanded' : ''}"
|
class="w-4 h-4 transition-transform duration-200 {isExpanded
|
||||||
|
? 'rotate-90'
|
||||||
|
: ''}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="label">{item.label}</span>
|
<span class="whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
>{item.label}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if isExpanded && item.children && item.children.length > 0}
|
{#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}
|
{#each item.children as child}
|
||||||
<svelte:self
|
<svelte:self
|
||||||
item={child}
|
item={child}
|
||||||
@ -66,94 +80,27 @@
|
|||||||
<!-- File item -->
|
<!-- File item -->
|
||||||
<a
|
<a
|
||||||
href={item.link}
|
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:click={(e) => onNavClick(item.link, e)}
|
||||||
on:keydown={(e) => handleKeyDown(e, false)}
|
on:keydown={(e) => handleKeyDown(e, false)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="tree-line" style="width: {indentation}px;"></div>
|
<div
|
||||||
<div class="icon-container">
|
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 -->
|
<!-- No file icon -->
|
||||||
</div>
|
</div>
|
||||||
<span class="label">{item.label}</span>
|
<span class="whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
>{item.label}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { Menu, Search, X } from "lucide-svelte";
|
import { Menu, Search, X } from "lucide-svelte";
|
||||||
import ThemeToggle from "../lib/theme/ThemeToggle.svelte";
|
import ThemeToggle from "../lib/theme/ThemeToggle.svelte";
|
||||||
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
|
|
||||||
export let toggleSidebar: () => void = () => {};
|
export let toggleSidebar: () => void = () => {};
|
||||||
export let isMobile: boolean = false;
|
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"
|
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">
|
<div class="flex items-center">
|
||||||
<button
|
<Button
|
||||||
class="mr-3 p-2 rounded-md hover:bg-background-secondary text-text"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="mr-3"
|
||||||
on:click={toggleSidebar}
|
on:click={toggleSidebar}
|
||||||
aria-label={sidebarVisible ? "Close sidebar" : "Open sidebar"}
|
aria-label={sidebarVisible ? "Close sidebar" : "Open sidebar"}
|
||||||
>
|
>
|
||||||
@ -21,7 +24,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<Menu class="h-5 w-5" />
|
<Menu class="h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</Button>
|
||||||
<div class="text-lg sm:text-xl font-bold text-primary-600">
|
<div class="text-lg sm:text-xl font-bold text-primary-600">
|
||||||
SecureWeb
|
SecureWeb
|
||||||
</div>
|
</div>
|
||||||
@ -29,11 +32,9 @@
|
|||||||
|
|
||||||
<div class="flex items-center space-x-2 sm:space-x-4">
|
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||||
<!-- Search button for mobile -->
|
<!-- Search button for mobile -->
|
||||||
<button
|
<Button variant="ghost" size="icon" class="md:hidden">
|
||||||
class="md:hidden p-2 rounded-md hover:bg-background-secondary text-text"
|
|
||||||
>
|
|
||||||
<Search class="h-5 w-5" />
|
<Search class="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<!-- Search bar for desktop -->
|
<!-- Search bar for desktop -->
|
||||||
<div class="relative hidden md:block">
|
<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 { onMount } from "svelte";
|
||||||
import NavItemComponent from "./NavItem.svelte";
|
import NavItemComponent from "./NavItem.svelte";
|
||||||
import { Menu, X } from "lucide-svelte";
|
import { Menu, X } from "lucide-svelte";
|
||||||
|
import Button from "$lib/components/ui/button/button.svelte";
|
||||||
|
|
||||||
export let navData: NavItem[] = [];
|
export let navData: NavItem[] = [];
|
||||||
export let onNavItemClick: (path: string) => void = () => {};
|
export let onNavItemClick: (path: string) => void = () => {};
|
||||||
@ -75,10 +76,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<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'}
|
{isMobile ? 'w-[85%] max-w-xs' : 'w-64'}
|
||||||
transition-transform duration-300 ease-in-out
|
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">
|
<nav class="w-full py-2">
|
||||||
{#each navData as item}
|
{#each navData as item}
|
||||||
@ -95,32 +97,13 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{#if isMobile && !visible}
|
{#if isMobile && !visible}
|
||||||
<button
|
<Button
|
||||||
class="fixed top-4 left-4 z-10 p-2 bg-background-secondary rounded-md shadow-md hover:bg-background"
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
class="fixed top-4 left-4 z-10 shadow-md"
|
||||||
on:click={toggleSidebar}
|
on:click={toggleSidebar}
|
||||||
aria-label="Open sidebar"
|
aria-label="Open sidebar"
|
||||||
>
|
>
|
||||||
<Menu class="w-5 h-5 text-text" />
|
<Menu class="w-5 h-5" />
|
||||||
</button>
|
</Button>
|
||||||
{/if}
|
{/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