diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e7a7aed
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Dependencies
+node_modules/
+Server/node_modules/
+Server/backend/node_modules/
+
+# Build outputs
+dist/
+build/
+*.log
+
+# Environment variables
+.env
+.env.local
+.env.*.local
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Cursor
+.cursor/debug.log
diff --git a/Server b/Server
deleted file mode 160000
index 6100df3..0000000
--- a/Server
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 6100df3f8340ed5df41cc21c4042320d7013a363
diff --git a/Server/appwrite.json b/Server/appwrite.json
new file mode 100644
index 0000000..ebc990c
--- /dev/null
+++ b/Server/appwrite.json
@@ -0,0 +1,3 @@
+{
+ "projectId": "696b82bb0036d2e547ad"
+}
diff --git a/Server/backend/package.json b/Server/backend/package.json
new file mode 100644
index 0000000..62630b2
--- /dev/null
+++ b/Server/backend/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "eship-backend",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "server.js",
+ "scripts": {
+ "dev": "node server.js",
+ "start": "node server.js"
+ },
+ "dependencies": {
+ "express": "^4.18.2",
+ "node-appwrite": "^14.0.0",
+ "dotenv": "^16.3.1"
+ }
+}
diff --git a/Server/backend/server.js b/Server/backend/server.js
new file mode 100644
index 0000000..ac12809
--- /dev/null
+++ b/Server/backend/server.js
@@ -0,0 +1,54 @@
+import express from "express";
+import { Client, Account, Databases } from "node-appwrite";
+import dotenv from "dotenv";
+
+dotenv.config();
+
+const app = express();
+app.use(express.json());
+
+const PORT = process.env.PORT || 3000;
+
+function makeUserClient(jwt) {
+ const client = new Client()
+ .setEndpoint(process.env.APPWRITE_ENDPOINT)
+ .setProject(process.env.APPWRITE_PROJECT_ID)
+ .setJWT(jwt);
+ return client;
+}
+
+function makeAdminClient() {
+ const client = new Client()
+ .setEndpoint(process.env.APPWRITE_ENDPOINT)
+ .setProject(process.env.APPWRITE_PROJECT_ID)
+ .setKey(process.env.APPWRITE_API_KEY);
+ return client;
+}
+
+app.post("/api/action", async (req, res) => {
+ try {
+ const auth = req.headers.authorization || "";
+ const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
+ if (!jwt) return res.status(401).json({ ok: false, error: "missing token" });
+
+ // 1) user token validieren
+ const userClient = makeUserClient(jwt);
+ const account = new Account(userClient);
+ const user = await account.get(); // wirft Fehler, wenn JWT ungueltig/abgelaufen
+
+ // 2) privilegierte Aktion nur serverseitig mit Admin Key
+ const adminClient = makeAdminClient();
+ const db = new Databases(adminClient);
+
+ // Beispiel: lies etwas, das nur du lesen darfst
+ // const data = await db.listDocuments("dbId", "collectionId");
+
+ return res.json({ ok: true, userId: user.$id, info: "action allowed" });
+ } catch (e) {
+ return res.status(401).json({ ok: false, error: "unauthorized" });
+ }
+});
+
+app.listen(PORT, () => {
+ console.log(`Backend server running on port ${PORT}`);
+});
diff --git a/Server/index.html b/Server/index.html
new file mode 100644
index 0000000..b2cd577
--- /dev/null
+++ b/Server/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ {filters.map((filter, idx) => {
+ if (filter.type === "select") {
+ return (
+
+ );
+ }
+ if (filter.type === "input") {
+ return (
+ onChange(filter.id, e.target.value)}
+ className="rounded-xl border border-[var(--line)] bg-transparent px-3 py-2.5 text-xs text-[var(--text)] outline-none transition-colors hover:border-[rgba(106,166,255,0.55)]"
+ />
+ );
+ }
+ return null;
+ })}
+
+ );
+};
diff --git a/Server/src/components/dashboard/ui/InsightCard.jsx b/Server/src/components/dashboard/ui/InsightCard.jsx
new file mode 100644
index 0000000..5ed4100
--- /dev/null
+++ b/Server/src/components/dashboard/ui/InsightCard.jsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+export const InsightCard = ({ title, value, subtitle, items, actionButton }) => {
+ return (
+
+ {cells.map((idx) => {
+ const rowIdx = Math.floor(idx / cols);
+ const colIdx = idx % cols;
+ const distance = clickedCell
+ ? Math.hypot(clickedCell.row - rowIdx, clickedCell.col - colIdx)
+ : 0;
+ const delay = clickedCell ? Math.max(0, distance * 55) : 0; // ms
+ const duration = 200 + distance * 80; // ms
+
+ const style = clickedCell
+ ? {
+ "--delay": `${delay}ms`,
+ "--duration": `${duration}ms`,
+ }
+ : {};
+
+ return (
+
onCellClick?.(rowIdx, colIdx) : undefined
+ } />
+ );
+ })}
+
+ );
+};
diff --git a/Server/src/components/layout/Logo.jsx b/Server/src/components/layout/Logo.jsx
new file mode 100644
index 0000000..7ecd7c0
--- /dev/null
+++ b/Server/src/components/layout/Logo.jsx
@@ -0,0 +1,16 @@
+import React from "react";
+import { motion } from "motion/react";
+
+export const Logo = () => {
+ return (
+
+
+
+ );
+};
diff --git a/Server/src/components/layout/LogoSquare.jsx b/Server/src/components/layout/LogoSquare.jsx
new file mode 100644
index 0000000..9813675
--- /dev/null
+++ b/Server/src/components/layout/LogoSquare.jsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+export const LogoSquare = () => {
+ return (
+
+
+
+ );
+};
diff --git a/Server/src/components/layout/LogoVertical.jsx b/Server/src/components/layout/LogoVertical.jsx
new file mode 100644
index 0000000..2f577cb
--- /dev/null
+++ b/Server/src/components/layout/LogoVertical.jsx
@@ -0,0 +1,15 @@
+import React from "react";
+
+export const LogoVertical = () => {
+ return (
+
+
+
+ );
+};
diff --git a/Server/src/components/onboarding/OnboardingGate.jsx b/Server/src/components/onboarding/OnboardingGate.jsx
new file mode 100644
index 0000000..c311c75
--- /dev/null
+++ b/Server/src/components/onboarding/OnboardingGate.jsx
@@ -0,0 +1,477 @@
+"use client";
+import React, { useState } from "react";
+import { motion, AnimatePresence } from "motion/react";
+import { BackgroundRippleEffect } from "@/components/layout/BackgroundRippleEffect";
+import Shuffle from "@/components/ui/Shuffle";
+import ColourfulText from "@/components/ui/colourful-text";
+import { PlaceholdersAndVanishInput } from "@/components/ui/placeholders-and-vanish-input";
+import { MultiStepLoader } from "@/components/ui/multi-step-loader";
+import { IPhoneNotification } from "@/components/ui/iphone-notification";
+import { parseEbayAccount } from "@/services/ebayParserService";
+import { createManagedAccount, fetchManagedAccounts } from "@/services/accountsService";
+import { account, databases, databaseId, usersCollectionId } from "@/lib/appwrite";
+
+export const OnboardingGate = ({ userName, onStart, loading, error }) => {
+ const [phase, setPhase] = useState("shuffle");
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+ const [showNotification, setShowNotification] = useState(false);
+ const [notificationMessage, setNotificationMessage] = useState("bitte eine gueltige url verwenden!");
+ const [skipHovered, setSkipHovered] = useState(false);
+
+ const loadingStates = [
+ { text: "URL wird verarbeitet..." },
+ { text: "Account-Daten werden geladen..." },
+ { text: "Account wird erstellt..." },
+ { text: "Fast fertig..." },
+ ];
+
+ const placeholders = [
+ "Gib deine eBay-Account-URL ein...",
+ "z.B. https://www.ebay.de/str/username",
+ "Paste deine eBay Shop-URL hier...",
+ ];
+
+ const handleOverlayClick = async (e) => {
+ // Only handle clicks on the overlay itself or when in shuffle phase
+ if (phase === "shuffle") {
+ // Create user document first (but don't call onStart, as that would hide the gate)
+ // We need to create it directly to ensure it exists for account creation later
+ try {
+ const authUser = await account.get();
+ if (authUser && databases && databaseId && usersCollectionId) {
+ try {
+ await databases.createDocument(
+ databaseId,
+ usersCollectionId,
+ authUser.$id,
+ {
+ user_name: authUser.name || "User"
+ }
+ );
+ } catch (docErr) {
+ // 409 Conflict means document already exists - that's ok
+ if (docErr.code !== 409 && docErr.type !== 'document_already_exists') {
+ console.warn("Fehler beim Erstellen des User-Dokuments:", docErr);
+ }
+ }
+ }
+ } catch (err) {
+ console.error("Fehler beim Abrufen des Users oder Erstellen des Dokuments:", err);
+ // Continue anyway - might already exist
+ }
+
+ // Add small delay to let the shuffle exit animation complete
+ // Exit animation is 0.5s, so we wait a bit longer to see it fully
+ setTimeout(() => {
+ setPhase("input");
+ }, 600); // 600ms delay - slightly longer than exit animation (500ms)
+ }
+ };
+
+ const handleSkip = async () => {
+ // Skip onboarding - directly call onStart to proceed to dashboard
+ // Must await to ensure User document is created before proceeding
+ if (onStart) {
+ await onStart();
+ }
+ };
+
+ // Validate eBay URL
+ const isValidEbayUrl = (url) => {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:45',message:'isValidEbayUrl: entry',data:{url,hasUrl:!!url,trimmedUrl:url?.trim()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+
+ if (!url || !url.trim()) {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:48',message:'isValidEbayUrl: empty url',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+ return false;
+ }
+
+ // Pattern for hostname only (without protocol/path)
+ const ebayHostnamePattern = /^(www\.)?(ebay\.(de|com|co\.uk|fr|it|es|nl|at|ch|com\.au|ca)|ebay-kleinanzeigen\.de)$/i;
+
+ try {
+ const urlObj = new URL(url);
+ const hostname = urlObj.hostname;
+ const patternMatches = ebayHostnamePattern.test(hostname);
+
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:62',message:'isValidEbayUrl: validation result',data:{url,hostname,patternMatches,pattern:ebayHostnamePattern.toString()},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+
+ return patternMatches;
+ } catch (e) {
+ // Invalid URL format
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:62',message:'isValidEbayUrl: URL parse error',data:{url,error:e.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+ return false;
+ }
+ };
+
+ const handleAccountSubmit = async (e, url) => {
+ e.preventDefault();
+
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:59',message:'handleAccountSubmit: entry',data:{url,hasUrl:!!url,phase},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
+ // #endregion
+
+ if (!url || !url.trim()) {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:64',message:'handleAccountSubmit: empty url, showing notification',data:{},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
+ // #endregion
+ setNotificationMessage("bitte eine gueltige url verwenden!");
+ setShowNotification(true);
+ return;
+ }
+
+ // Validate eBay URL
+ const isValid = isValidEbayUrl(url);
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:71',message:'handleAccountSubmit: validation result',data:{url,isValid},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+
+ if (!isValid) {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:76',message:'handleAccountSubmit: invalid url, showing notification',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'A'})}).catch(()=>{});
+ // #endregion
+ setNotificationMessage("bitte eine gueltige url verwenden!");
+ setShowNotification(true);
+ return;
+ }
+
+ setSubmitting(true);
+ setSubmitError("");
+
+ // Wait 2 seconds for vanish animation, then switch to loading phase
+ setTimeout(async () => {
+ // Switch to loading phase first
+ setPhase("loading");
+
+ try {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:88',message:'handleAccountSubmit: before parseEbayAccount',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
+ // #endregion
+
+ // Parse eBay account
+ const accountData = await parseEbayAccount(url);
+
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:93',message:'handleAccountSubmit: parseEbayAccount success',data:{url,hasAccountData:!!accountData,market:accountData?.market,sellerId:accountData?.sellerId},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
+ // #endregion
+
+ // Validate that sellerId was extracted successfully
+ if (!accountData.sellerId || accountData.sellerId.trim() === "") {
+ throw new Error("Die eBay-URL konnte nicht verarbeitet werden. Bitte stelle sicher, dass es eine gültige eBay-Shop-URL ist.");
+ }
+
+ // Get current user
+ const authUser = await account.get();
+
+ // Check for duplicate account before creating
+ const existingAccounts = await fetchManagedAccounts(authUser.$id);
+ const duplicateAccount = existingAccounts.find(
+ (acc) =>
+ acc.account_platform_account_id === accountData.sellerId &&
+ acc.account_platform_market === accountData.market
+ );
+
+ if (duplicateAccount) {
+ throw new Error("Dieser Account ist bereits verbunden.");
+ }
+
+ // Create account in database
+ await createManagedAccount(authUser.$id, {
+ account_platform_market: accountData.market,
+ account_platform_account_id: accountData.sellerId,
+ account_shop_name: accountData.shopName || null,
+ account_url: url,
+ account_status: accountData.status || "active",
+ });
+
+ // Account erstellt erfolgreich, rufe onStart auf
+ onStart && onStart();
+
+ // Trigger event to reload Dashboard accounts
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:142',message:'handleAccountSubmit: account created, triggering reload event',data:{url},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'B'})}).catch(()=>{});
+ // #endregion
+ window.dispatchEvent(new CustomEvent('accountsUpdated'));
+ } catch (err) {
+ console.error("Fehler beim Erstellen des Accounts:", err);
+
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:106',message:'handleAccountSubmit: error caught',data:{url,errorMessage:err.message,errorCode:err.code,errorType:err.type,errorStack:err.stack?.substring(0,200)},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
+ // #endregion
+
+ // Show notification for URL validation, duplicate, or parsing errors
+ if (
+ err.message?.includes("URL") ||
+ err.message?.includes("ungültig") ||
+ err.message?.includes("invalid") ||
+ err.message?.includes("bereits verbunden") ||
+ err.message?.includes("already")
+ ) {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:205',message:'handleAccountSubmit: URL/duplicate error, showing notification',data:{errorMessage:err.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
+ // #endregion
+ // Use the actual error message or default message
+ setNotificationMessage(err.message || "bitte eine gueltige url verwenden!");
+ setShowNotification(true);
+ } else {
+ // #region agent log
+ fetch('http://127.0.0.1:7242/ingest/246fe132-ecc5-435f-bd9c-fe5e8e26089d',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'OnboardingGate.jsx:175',message:'handleAccountSubmit: non-URL error, setting submitError',data:{errorMessage:err.message},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
+ // #endregion
+ setSubmitError(err.message || "Fehler beim Erstellen des Accounts. Bitte versuche es erneut.");
+ }
+ setPhase("input");
+ setSubmitting(false);
+ }
+ }, 2000); // 2 second delay for vanish animation
+ };
+
+ return (
+
+
+
+ {/* Skip Button - Bottom Left */}
+
+
+ {/* iPhone-style Notification */}
+
setShowNotification(false)}
+ duration={4000}
+ />
+
+ {/* Shuffle and Input Phases - Combined AnimatePresence for smooth transition */}
+
+ {phase === "shuffle" && (
+ {
+ e.stopPropagation(); // Prevent double-triggering
+ handleOverlayClick(e);
+ }}
+ >
+
+
+ ,
+
+
+
+ )}
+
+ {phase === "input" && (
+
+
+
+ Gib deine
+
+ ein
+
+
+
{
+ setSubmitError("");
+ setShowNotification(false);
+ setNotificationMessage("bitte eine gueltige url verwenden!"); // Reset to default
+ }}
+ onSubmit={handleAccountSubmit}
+ />
+
+ {(submitError || error) && !showNotification && (
+
+ {submitError || error}
+
+ )}
+
+
+ )}
+
+
+ {/* Loading Phase */}
+ {phase === "loading" && (
+
+ )}
+
+ );
+};
+
+const styles = {
+ overlay: {
+ position: "fixed",
+ inset: 0,
+ background: "rgba(0,0,0,0.75)",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: 18,
+ zIndex: 9998,
+ overflow: "hidden",
+ },
+ centerContent: {
+ position: "relative",
+ zIndex: 10, // Higher than BackgroundRippleEffect (z-[3] = z-index: 3) to ensure input is clickable
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "100%",
+ maxWidth: "800px",
+ padding: "0 20px",
+ cursor: "default",
+ },
+ shuffleContainer: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ flexWrap: "wrap",
+ gap: "0",
+ },
+ staticComma: {
+ fontSize: "4rem",
+ fontWeight: 800,
+ color: "#e7aaf0",
+ marginLeft: "0.1em",
+ marginRight: "0.1em",
+ display: "inline-block",
+ },
+ inputContainer: {
+ width: "100%",
+ maxWidth: "600px",
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: "24px",
+ },
+ colourfulTextContainer: {
+ display: "flex",
+ alignItems: "center",
+ flexWrap: "wrap",
+ justifyContent: "center",
+ gap: "4px",
+ fontSize: "24px",
+ fontWeight: 600,
+ color: "#e7aaf0",
+ marginBottom: "8px",
+ textAlign: "center",
+ },
+ textPrefix: {
+ color: "#e7aaf0",
+ },
+ error: {
+ marginTop: 12,
+ color: "#ffb3b3",
+ fontSize: 13,
+ textAlign: "center",
+ padding: "8px 16px",
+ backgroundColor: "rgba(255, 179, 179, 0.1)",
+ borderRadius: "8px",
+ border: "1px solid rgba(255, 179, 179, 0.3)",
+ },
+ skipButton: {
+ position: "fixed",
+ bottom: 20,
+ left: 20,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "flex-start",
+ width: "32px",
+ height: "32px",
+ border: "none",
+ borderRadius: "50%",
+ cursor: "pointer",
+ backgroundColor: "rgb(255, 65, 65)",
+ boxShadow: "2px 2px 10px rgba(0, 0, 0, 0.199)",
+ transition: "all 0.3s ease",
+ overflow: "hidden",
+ zIndex: 10001,
+ },
+ skipSign: {
+ width: "100%",
+ transition: "all 0.3s ease",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ skipText: {
+ position: "absolute",
+ right: "0%",
+ width: "0%",
+ opacity: 0,
+ color: "white",
+ fontSize: "0.9em",
+ fontWeight: 600,
+ transition: "all 0.3s ease",
+ whiteSpace: "nowrap",
+ },
+};
diff --git a/Server/src/components/sidebar/Sidebar.jsx b/Server/src/components/sidebar/Sidebar.jsx
new file mode 100644
index 0000000..e5bad4b
--- /dev/null
+++ b/Server/src/components/sidebar/Sidebar.jsx
@@ -0,0 +1,151 @@
+"use client";
+import { cn } from "../../lib/utils";
+import React, { useState, createContext, useContext } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { IconMenu2, IconX } from "@tabler/icons-react";
+
+const SidebarContext = createContext(undefined);
+
+export const useSidebar = () => {
+ const context = useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider");
+ }
+ return context;
+};
+
+export const SidebarProvider = ({
+ children,
+ open: openProp,
+ setOpen: setOpenProp,
+ animate = true
+}) => {
+ const [openState, setOpenState] = useState(false);
+
+ const open = openProp !== undefined ? openProp : openState;
+ const setOpen = setOpenProp !== undefined ? setOpenProp : setOpenState;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const Sidebar = ({
+ children,
+ open,
+ setOpen,
+ animate
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const SidebarBody = (props) => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export const DesktopSidebar = ({
+ className,
+ children,
+ ...props
+}) => {
+ const { open, setOpen, animate } = useSidebar();
+ return (
+ <>
+
setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ {...props}>
+ {children}
+
+ >
+ );
+};
+
+export const MobileSidebar = ({
+ className,
+ children,
+ ...props
+}) => {
+ const { open, setOpen } = useSidebar();
+ return (
+ <>
+
+
+ setOpen(!open)} />
+
+
+ {open && (
+
+ setOpen(!open)}>
+
+
+ {children}
+
+ )}
+
+
+ >
+ );
+};
+
+export const SidebarLink = ({
+ link,
+ className,
+ ...props
+}) => {
+ const { open, animate } = useSidebar();
+ return (
+
+ {link.icon}
+
+ {link.label}
+
+
+ );
+};
diff --git a/Server/src/components/sidebar/SidebarHeader.jsx b/Server/src/components/sidebar/SidebarHeader.jsx
new file mode 100644
index 0000000..79c3251
--- /dev/null
+++ b/Server/src/components/sidebar/SidebarHeader.jsx
@@ -0,0 +1,199 @@
+"use client";
+import React, { useState, useEffect, useRef } from "react";
+import { IconChevronDown } from "@tabler/icons-react";
+import { motion, AnimatePresence } from "motion/react";
+import { cn } from "../../lib/utils";
+import { useHashRoute } from "../../lib/routing";
+import {
+ getActiveAccountId,
+ setActiveAccountId,
+ resolveActiveAccount,
+ getAccountDisplayName,
+} from "../../services/accountService";
+import { fetchManagedAccounts } from "../../services/accountsService";
+import { getAuthUser } from "../../lib/appwrite";
+
+export const SidebarHeader = () => {
+ const { navigate } = useHashRoute();
+ const [accounts, setAccounts] = useState([]);
+ const [activeAccount, setActiveAccount] = useState(null);
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const dropdownRef = useRef(null);
+
+ // Accounts laden
+ useEffect(() => {
+ async function loadAccounts() {
+ setLoading(true);
+ try {
+ const authUser = await getAuthUser();
+ if (!authUser) {
+ setAccounts([]);
+ setActiveAccount(null);
+ return;
+ }
+
+ const loadedAccounts = await fetchManagedAccounts(authUser.$id);
+ setAccounts(loadedAccounts);
+ const active = resolveActiveAccount(loadedAccounts);
+ setActiveAccount(active);
+ } catch (e) {
+ console.error("Fehler beim Laden der Accounts:", e);
+ setAccounts([]);
+ setActiveAccount(null);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ loadAccounts();
+ }, []);
+
+ // Event-Listener für activeAccountChanged (wenn Account in anderer Komponente gewechselt wird)
+ useEffect(() => {
+ function handleActiveAccountChanged(event) {
+ const { accountId } = event.detail;
+ // Account aus aktueller Liste finden und als aktiv setzen
+ const foundAccount = accounts.find(
+ (acc) => acc.$id === accountId || acc.id === accountId
+ );
+ if (foundAccount) {
+ setActiveAccount(foundAccount);
+ }
+ }
+
+ window.addEventListener("activeAccountChanged", handleActiveAccountChanged);
+ return () => {
+ window.removeEventListener("activeAccountChanged", handleActiveAccountChanged);
+ };
+ }, [accounts]);
+
+ // Click outside handler für Dropdown
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setDropdownOpen(false);
+ }
+ }
+
+ if (dropdownOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }
+ }, [dropdownOpen]);
+
+ const handleHeaderClick = () => {
+ if (accounts.length === 0) {
+ // Keine Accounts: direkt zu /accounts navigieren
+ navigate("/accounts");
+ } else {
+ // Accounts existieren: Dropdown öffnen/schließen
+ setDropdownOpen(!dropdownOpen);
+ }
+ };
+
+ const handleAccountSelect = (account) => {
+ const accountId = account.$id || account.id;
+ setActiveAccountId(accountId);
+ setActiveAccount(account);
+ setDropdownOpen(false);
+ };
+
+ const handleManageAccounts = () => {
+ setDropdownOpen(false);
+ navigate("/accounts");
+ };
+
+ const displayText =
+ accounts.length === 0
+ ? "Add Account"
+ : activeAccount
+ ? getAccountDisplayName(activeAccount)
+ : "Add Account";
+
+ return (
+
+
+
+
+ {dropdownOpen && accounts.length > 0 && (
+
+
+ {accounts.map((account) => {
+ const accountId = account.$id || account.id;
+ const currentActiveId = getActiveAccountId();
+ const isActive = accountId === currentActiveId;
+ const displayName = getAccountDisplayName(account);
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/Server/src/components/sidebar/index.js b/Server/src/components/sidebar/index.js
new file mode 100644
index 0000000..a5f8b0b
--- /dev/null
+++ b/Server/src/components/sidebar/index.js
@@ -0,0 +1 @@
+export { Sidebar, SidebarBody, SidebarLink, useSidebar } from "./Sidebar.jsx";
diff --git a/Server/src/components/ui/LogoutButton.jsx b/Server/src/components/ui/LogoutButton.jsx
new file mode 100644
index 0000000..a8d1c08
--- /dev/null
+++ b/Server/src/components/ui/LogoutButton.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import styled from 'styled-components';
+
+const LogoutButton = ({ onClick }) => {
+ return (
+
+
+
+ );
+}
+
+const StyledWrapper = styled.div`
+ .Btn {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+ transition-duration: .3s;
+ box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.199);
+ background-color: rgb(255, 65, 65);
+ }
+
+ /* plus sign */
+ .sign {
+ width: 100%;
+ transition-duration: .3s;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .sign svg {
+ width: 12px;
+ }
+
+ .sign svg path {
+ fill: white;
+ }
+ /* text */
+ .text {
+ position: absolute;
+ right: 0%;
+ width: 0%;
+ opacity: 0;
+ color: white;
+ font-size: 0.9em;
+ font-weight: 600;
+ transition-duration: .3s;
+ }
+ /* hover effect on button width */
+ .Btn:hover {
+ width: 90px;
+ border-radius: 40px;
+ transition-duration: .3s;
+ }
+
+ .Btn:hover .sign {
+ width: 30%;
+ transition-duration: .3s;
+ padding-left: 15px;
+ }
+ /* hover effect button's text */
+ .Btn:hover .text {
+ opacity: 1;
+ width: 70%;
+ transition-duration: .3s;
+ padding-right: 8px;
+ }
+ /* button click effect*/
+ .Btn:active {
+ transform: translate(2px ,2px);
+ }`;
+
+export default LogoutButton;
diff --git a/Server/src/components/ui/Shuffle.jsx b/Server/src/components/ui/Shuffle.jsx
new file mode 100644
index 0000000..be5e0e7
--- /dev/null
+++ b/Server/src/components/ui/Shuffle.jsx
@@ -0,0 +1,392 @@
+"use client";
+import React, { useRef, useEffect, useState, useMemo } from 'react';
+import { gsap } from 'gsap';
+import { ScrollTrigger } from 'gsap/ScrollTrigger';
+import { SplitText as GSAPSplitText } from 'gsap/SplitText';
+import { useGSAP } from '@gsap/react';
+
+gsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);
+
+const Shuffle = ({
+ text,
+ className = '',
+ style = {},
+ shuffleDirection = 'right',
+ duration = 0.35,
+ maxDelay = 0,
+ ease = 'power3.out',
+ threshold = 0.1,
+ rootMargin = '-100px',
+ tag = 'p',
+ textAlign = 'center',
+ onShuffleComplete,
+ shuffleTimes = 1,
+ animationMode = 'evenodd',
+ loop = false,
+ loopDelay = 0,
+ stagger = 0.03,
+ scrambleCharset = '',
+ colorFrom,
+ colorTo,
+ triggerOnce = true,
+ respectReducedMotion = true,
+ triggerOnHover = true
+}) => {
+ const ref = useRef(null);
+ const [fontsLoaded, setFontsLoaded] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ const splitRef = useRef(null);
+ const wrappersRef = useRef([]);
+ const tlRef = useRef(null);
+ const playingRef = useRef(false);
+ const hoverHandlerRef = useRef(null);
+
+ const userHasFont = useMemo(
+ () => (style && style.fontFamily) || (className && /font[-[]/i.test(className)),
+ [style, className]
+ );
+
+ const scrollTriggerStart = useMemo(() => {
+ const startPct = (1 - threshold) * 100;
+ const mm = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');
+ const mv = mm ? parseFloat(mm[1]) : 0;
+ const mu = mm ? mm[2] || 'px' : 'px';
+ const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;
+ return `top ${startPct}%${sign}`;
+ }, [threshold, rootMargin]);
+
+ useEffect(() => {
+ if ('fonts' in document) {
+ if (document.fonts.status === 'loaded') setFontsLoaded(true);
+ else document.fonts.ready.then(() => setFontsLoaded(true));
+ } else setFontsLoaded(true);
+ }, []);
+
+ useGSAP(
+ () => {
+ if (!ref.current || !text || !fontsLoaded) return;
+
+ if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ onShuffleComplete?.();
+ return;
+ }
+
+ const el = ref.current;
+
+ let computedFont = '';
+ if (userHasFont) {
+ computedFont = style.fontFamily || getComputedStyle(el).fontFamily || '';
+ } else {
+ computedFont = `'Press Start 2P', sans-serif`;
+ }
+
+ const start = scrollTriggerStart;
+
+ const removeHover = () => {
+ if (hoverHandlerRef.current && ref.current) {
+ ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);
+ hoverHandlerRef.current = null;
+ }
+ };
+
+ const teardown = () => {
+ if (tlRef.current) {
+ tlRef.current.kill();
+ tlRef.current = null;
+ }
+ if (wrappersRef.current.length) {
+ wrappersRef.current.forEach(wrap => {
+ const inner = wrap.firstElementChild;
+ const orig = inner?.querySelector('[data-orig="1"]');
+ if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);
+ });
+ wrappersRef.current = [];
+ }
+ try {
+ splitRef.current?.revert();
+ } catch {
+ /* noop */
+ }
+ splitRef.current = null;
+ playingRef.current = false;
+ };
+
+ const build = () => {
+ teardown();
+
+ splitRef.current = new GSAPSplitText(el, {
+ type: 'chars',
+ charsClass: 'shuffle-char',
+ wordsClass: 'shuffle-word',
+ linesClass: 'shuffle-line',
+ smartWrap: true,
+ reduceWhiteSpace: false
+ });
+
+ const chars = splitRef.current.chars || [];
+ wrappersRef.current = [];
+
+ const rolls = Math.max(1, Math.floor(shuffleTimes));
+ const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';
+
+ chars.forEach(ch => {
+ const parent = ch.parentElement;
+ if (!parent) return;
+
+ const w = ch.getBoundingClientRect().width;
+ const h = ch.getBoundingClientRect().height;
+ if (!w) return;
+
+ const wrap = document.createElement('span');
+ wrap.className = 'inline-block overflow-hidden text-left';
+ Object.assign(wrap.style, {
+ width: w + 'px',
+ height: shuffleDirection === 'up' || shuffleDirection === 'down' ? h + 'px' : 'auto',
+ verticalAlign: 'bottom'
+ });
+
+ const inner = document.createElement('span');
+ inner.className =
+ 'inline-block will-change-transform origin-left transform-gpu ' +
+ (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'whitespace-normal' : 'whitespace-nowrap');
+
+ parent.insertBefore(wrap, ch);
+ wrap.appendChild(inner);
+
+ const firstOrig = ch.cloneNode(true);
+ firstOrig.className =
+ 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
+ Object.assign(firstOrig.style, { width: w + 'px', fontFamily: computedFont });
+
+ ch.setAttribute('data-orig', '1');
+ ch.className =
+ 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
+ Object.assign(ch.style, { width: w + 'px', fontFamily: computedFont });
+
+ inner.appendChild(firstOrig);
+ for (let k = 0; k < rolls; k++) {
+ const c = ch.cloneNode(true);
+ if (scrambleCharset) c.textContent = rand(scrambleCharset);
+ c.className =
+ 'text-left ' + (shuffleDirection === 'up' || shuffleDirection === 'down' ? 'block' : 'inline-block');
+ Object.assign(c.style, { width: w + 'px', fontFamily: computedFont });
+ inner.appendChild(c);
+ }
+ inner.appendChild(ch);
+
+ const steps = rolls + 1;
+
+ if (shuffleDirection === 'right' || shuffleDirection === 'down') {
+ const firstCopy = inner.firstElementChild;
+ const real = inner.lastElementChild;
+ if (real) inner.insertBefore(real, inner.firstChild);
+ if (firstCopy) inner.appendChild(firstCopy);
+ }
+
+ let startX = 0;
+ let finalX = 0;
+ let startY = 0;
+ let finalY = 0;
+
+ if (shuffleDirection === 'right') {
+ startX = -steps * w;
+ finalX = 0;
+ } else if (shuffleDirection === 'left') {
+ startX = 0;
+ finalX = -steps * w;
+ } else if (shuffleDirection === 'down') {
+ startY = -steps * h;
+ finalY = 0;
+ } else if (shuffleDirection === 'up') {
+ startY = 0;
+ finalY = -steps * h;
+ }
+
+ if (shuffleDirection === 'left' || shuffleDirection === 'right') {
+ gsap.set(inner, { x: startX, y: 0, force3D: true });
+ inner.setAttribute('data-start-x', String(startX));
+ inner.setAttribute('data-final-x', String(finalX));
+ } else {
+ gsap.set(inner, { x: 0, y: startY, force3D: true });
+ inner.setAttribute('data-start-y', String(startY));
+ inner.setAttribute('data-final-y', String(finalY));
+ }
+
+ if (colorFrom) inner.style.color = colorFrom;
+ wrappersRef.current.push(wrap);
+ });
+ };
+
+ const inners = () => wrappersRef.current.map(w => w.firstElementChild);
+
+ const randomizeScrambles = () => {
+ if (!scrambleCharset) return;
+ wrappersRef.current.forEach(w => {
+ const strip = w.firstElementChild;
+ if (!strip) return;
+ const kids = Array.from(strip.children);
+ for (let i = 1; i < kids.length - 1; i++) {
+ kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));
+ }
+ });
+ };
+
+ const cleanupToStill = () => {
+ wrappersRef.current.forEach(w => {
+ const strip = w.firstElementChild;
+ if (!strip) return;
+ const real = strip.querySelector('[data-orig="1"]');
+ if (!real) return;
+ strip.replaceChildren(real);
+ strip.style.transform = 'none';
+ strip.style.willChange = 'auto';
+ });
+ };
+
+ const play = () => {
+ const strips = inners();
+ if (!strips.length) return;
+
+ playingRef.current = true;
+ const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';
+
+ const tl = gsap.timeline({
+ smoothChildTiming: true,
+ repeat: loop ? -1 : 0,
+ repeatDelay: loop ? loopDelay : 0,
+ onRepeat: () => {
+ if (scrambleCharset) randomizeScrambles();
+ if (isVertical) {
+ gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });
+ } else {
+ gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });
+ }
+ onShuffleComplete?.();
+ },
+ onComplete: () => {
+ playingRef.current = false;
+ if (!loop) {
+ cleanupToStill();
+ if (colorTo) gsap.set(strips, { color: colorTo });
+ onShuffleComplete?.();
+ armHover();
+ }
+ }
+ });
+
+ const addTween = (targets, at) => {
+ const vars = {
+ duration,
+ ease,
+ force3D: true,
+ stagger: animationMode === 'evenodd' ? stagger : 0
+ };
+ if (isVertical) {
+ vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');
+ } else {
+ vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');
+ }
+
+ tl.to(targets, vars, at);
+
+ if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);
+ };
+
+ if (animationMode === 'evenodd') {
+ const odd = strips.filter((_, i) => i % 2 === 1);
+ const even = strips.filter((_, i) => i % 2 === 0);
+ const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;
+ const evenStart = odd.length ? oddTotal * 0.7 : 0;
+ if (odd.length) addTween(odd, 0);
+ if (even.length) addTween(even, evenStart);
+ } else {
+ strips.forEach(strip => {
+ const d = Math.random() * maxDelay;
+ const vars = {
+ duration,
+ ease,
+ force3D: true
+ };
+ if (isVertical) {
+ vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');
+ } else {
+ vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');
+ }
+ tl.to(strip, vars, d);
+ if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);
+ });
+ }
+
+ tlRef.current = tl;
+ };
+
+ const armHover = () => {
+ if (!triggerOnHover || !ref.current) return;
+ removeHover();
+ const handler = () => {
+ if (playingRef.current) return;
+ build();
+ if (scrambleCharset) randomizeScrambles();
+ play();
+ };
+ hoverHandlerRef.current = handler;
+ ref.current.addEventListener('mouseenter', handler);
+ };
+
+ const create = () => {
+ build();
+ if (scrambleCharset) randomizeScrambles();
+ play();
+ armHover();
+ setReady(true);
+ };
+
+ const st = ScrollTrigger.create({ trigger: el, start, once: triggerOnce, onEnter: create });
+
+ return () => {
+ st.kill();
+ removeHover();
+ teardown();
+ setReady(false);
+ };
+ },
+ {
+ dependencies: [
+ text,
+ duration,
+ maxDelay,
+ ease,
+ scrollTriggerStart,
+ fontsLoaded,
+ shuffleDirection,
+ shuffleTimes,
+ animationMode,
+ loop,
+ loopDelay,
+ stagger,
+ scrambleCharset,
+ colorFrom,
+ colorTo,
+ triggerOnce,
+ respectReducedMotion,
+ triggerOnHover,
+ onShuffleComplete,
+ userHasFont
+ ],
+ scope: ref
+ }
+ );
+
+ const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-[4rem] leading-none';
+ const classes = useMemo(
+ () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),
+ [baseTw, ready, className]
+ );
+ const Tag = tag || 'p';
+ const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);
+
+ return React.createElement(Tag, { ref: ref, className: classes, style: commonStyle }, text);
+};
+
+export default Shuffle;
diff --git a/Server/src/components/ui/colourful-text.jsx b/Server/src/components/ui/colourful-text.jsx
new file mode 100644
index 0000000..16b0d88
--- /dev/null
+++ b/Server/src/components/ui/colourful-text.jsx
@@ -0,0 +1,52 @@
+"use client";
+import React from "react";
+import { motion } from "motion/react";
+
+export default function ColourfulText({ text }) {
+ const colors = [
+ "rgb(131, 179, 32)",
+ "rgb(47, 195, 106)",
+ "rgb(42, 169, 210)",
+ "rgb(4, 112, 202)",
+ "rgb(107, 10, 255)",
+ "rgb(183, 0, 218)",
+ "rgb(218, 0, 171)",
+ "rgb(230, 64, 92)",
+ "rgb(232, 98, 63)",
+ "rgb(249, 129, 47)",
+ ];
+
+ const [currentColors, setCurrentColors] = React.useState(colors);
+ const [count, setCount] = React.useState(0);
+
+ React.useEffect(() => {
+ const interval = setInterval(() => {
+ const shuffled = [...colors].sort(() => Math.random() - 0.5);
+ setCurrentColors(shuffled);
+ setCount((prev) => prev + 1);
+ }, 5000);
+
+ return () => clearInterval(interval);
+ }, []);
+
+ return text.split("").map((char, index) => (
+
+ {char}
+
+ ));
+}
diff --git a/Server/src/components/ui/iphone-notification.jsx b/Server/src/components/ui/iphone-notification.jsx
new file mode 100644
index 0000000..0bea74c
--- /dev/null
+++ b/Server/src/components/ui/iphone-notification.jsx
@@ -0,0 +1,107 @@
+"use client";
+import React, { useState } from "react";
+import { motion, AnimatePresence } from "motion/react";
+
+export function IPhoneNotification({ show, title, message, onClose, duration = 3000 }) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ React.useEffect(() => {
+ if (show && duration > 0) {
+ const timer = setTimeout(() => {
+ onClose && onClose();
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }
+ }, [show, duration, onClose]);
+
+ return (
+
+ {show && (
+ setIsHovered(true)}
+ onHoverEnd={() => setIsHovered(false)}
+ whileHover={{ scale: 1.05 }}
+ >
+
+
+
+ )}
+
+ );
+}
+
+const styles = {
+ card: {
+ position: "fixed",
+ top: 20,
+ left: "50%",
+ width: "100%",
+ maxWidth: "290px",
+ height: "70px",
+ background: "#27272a", // zinc-800 - same grey as search bar
+ borderRadius: "20px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "left",
+ backdropFilter: "blur(10px)",
+ transition: "0.5s ease-in-out",
+ zIndex: 10000,
+ cursor: "pointer",
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.5)",
+ border: "1px solid rgba(231, 170, 240, 0.2)",
+ },
+ img: {
+ width: "50px",
+ height: "50px",
+ marginLeft: "10px",
+ borderRadius: "10px",
+ background: "linear-gradient(#d7cfcf, #9198e5)",
+ transition: "0.5s ease-in-out",
+ },
+ textBox: {
+ width: "calc(100% - 90px)",
+ marginLeft: "10px",
+ color: "#e7aaf0",
+ fontFamily: "'Poppins', sans-serif",
+ },
+ textContent: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ },
+ span: {
+ fontSize: "10px",
+ opacity: 0.7,
+ },
+ h1: {
+ fontSize: "16px",
+ fontWeight: "bold",
+ margin: 0,
+ },
+ p: {
+ fontSize: "12px",
+ fontWeight: "lighter",
+ margin: "4px 0 0 0",
+ opacity: 0.8,
+ },
+};
\ No newline at end of file
diff --git a/Server/src/components/ui/multi-step-loader.jsx b/Server/src/components/ui/multi-step-loader.jsx
new file mode 100644
index 0000000..a5b2d69
--- /dev/null
+++ b/Server/src/components/ui/multi-step-loader.jsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { AnimatePresence, motion } from "motion/react";
+
+function LoaderCore({ loadingStates, value }) {
+ return (
+
+ {loadingStates.map((state, index) => {
+ const distance = Math.abs(index - value);
+ const opacity = Math.max(1 - distance * 0.2, 0);
+ return (
+
+
+ {index > value ? (
+
+ ) : (
+
+ )}
+
+
+ {state.text}
+
+
+ );
+ })}
+
+ );
+}
+
+export function MultiStepLoader({
+ loadingStates,
+ loading = false,
+ duration = 2000,
+ loop = true,
+}) {
+ const [currentValue, setCurrentValue] = useState(0);
+
+ useEffect(() => {
+ if (!loading) {
+ setCurrentValue(0);
+ return;
+ }
+ const timer = setTimeout(() => {
+ setCurrentValue((prev) =>
+ loop
+ ? prev === loadingStates.length - 1
+ ? 0
+ : prev + 1
+ : Math.min(prev + 1, loadingStates.length - 1)
+ );
+ }, duration);
+
+ return () => clearTimeout(timer);
+ }, [currentValue, loading, loop, duration, loadingStates.length]);
+
+ return (
+
+ {loading && (
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/Server/src/components/ui/placeholders-and-vanish-input.jsx b/Server/src/components/ui/placeholders-and-vanish-input.jsx
new file mode 100644
index 0000000..474aac2
--- /dev/null
+++ b/Server/src/components/ui/placeholders-and-vanish-input.jsx
@@ -0,0 +1,262 @@
+"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+
+export function PlaceholdersAndVanishInput({
+ placeholders,
+ onChange,
+ onSubmit
+}) {
+ const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
+
+ const intervalRef = useRef(null);
+ const startAnimation = () => {
+ intervalRef.current = setInterval(() => {
+ setCurrentPlaceholder((prev) => (prev + 1) % placeholders.length);
+ }, 3000);
+ };
+ const handleVisibilityChange = () => {
+ if (document.visibilityState !== "visible" && intervalRef.current) {
+ clearInterval(intervalRef.current); // Clear the interval when the tab is not visible
+ intervalRef.current = null;
+ } else if (document.visibilityState === "visible") {
+ startAnimation(); // Restart the interval when the tab becomes visible
+ }
+ };
+
+ useEffect(() => {
+ startAnimation();
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ };
+ }, [placeholders]);
+
+ const canvasRef = useRef(null);
+ const newDataRef = useRef([]);
+ const inputRef = useRef(null);
+ const [value, setValue] = useState("");
+ const [animating, setAnimating] = useState(false);
+
+ const draw = useCallback(() => {
+ if (!inputRef.current) return;
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ canvas.width = 800;
+ canvas.height = 800;
+ ctx.clearRect(0, 0, 800, 800);
+ const computedStyles = getComputedStyle(inputRef.current);
+
+ const fontSize = parseFloat(computedStyles.getPropertyValue("font-size"));
+ ctx.font = `${fontSize * 2}px ${computedStyles.fontFamily}`;
+ ctx.fillStyle = "#FFF";
+ ctx.fillText(value, 16, 40);
+
+ const imageData = ctx.getImageData(0, 0, 800, 800);
+ const pixelData = imageData.data;
+ const newData = [];
+
+ for (let t = 0; t < 800; t++) {
+ let i = 4 * t * 800;
+ for (let n = 0; n < 800; n++) {
+ let e = i + 4 * n;
+ if (
+ pixelData[e] !== 0 &&
+ pixelData[e + 1] !== 0 &&
+ pixelData[e + 2] !== 0
+ ) {
+ newData.push({
+ x: n,
+ y: t,
+ color: [
+ pixelData[e],
+ pixelData[e + 1],
+ pixelData[e + 2],
+ pixelData[e + 3],
+ ],
+ });
+ }
+ }
+ }
+
+ newDataRef.current = newData.map(({ x, y, color }) => ({
+ x,
+ y,
+ r: 1,
+ color: `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3]})`,
+ }));
+ }, [value]);
+
+ useEffect(() => {
+ draw();
+ }, [value, draw]);
+
+ const animate = (start) => {
+ const animateFrame = (pos = 0) => {
+ requestAnimationFrame(() => {
+ const newArr = [];
+ for (let i = 0; i < newDataRef.current.length; i++) {
+ const current = newDataRef.current[i];
+ if (current.x < pos) {
+ newArr.push(current);
+ } else {
+ if (current.r <= 0) {
+ current.r = 0;
+ continue;
+ }
+ current.x += Math.random() > 0.5 ? 1 : -1;
+ current.y += Math.random() > 0.5 ? 1 : -1;
+ current.r -= 0.05 * Math.random();
+ newArr.push(current);
+ }
+ }
+ newDataRef.current = newArr;
+ const ctx = canvasRef.current?.getContext("2d");
+ if (ctx) {
+ ctx.clearRect(pos, 0, 800, 800);
+ newDataRef.current.forEach((t) => {
+ const { x: n, y: i, r: s, color: color } = t;
+ if (n > pos) {
+ ctx.beginPath();
+ ctx.rect(n, i, s, s);
+ ctx.fillStyle = color;
+ ctx.strokeStyle = color;
+ ctx.stroke();
+ }
+ });
+ }
+ if (newDataRef.current.length > 0) {
+ animateFrame(pos - 8);
+ } else {
+ setValue("");
+ setAnimating(false);
+ }
+ });
+ };
+ animateFrame(start);
+ };
+
+ const handleKeyDown = (e) => {
+ if (e.key === "Enter" && !animating) {
+ vanishAndSubmit();
+ }
+ };
+
+ const vanishAndSubmit = () => {
+ setAnimating(true);
+ draw();
+
+ const value = inputRef.current?.value || "";
+ if (value && inputRef.current) {
+ const maxX = newDataRef.current.reduce((prev, current) => (current.x > prev ? current.x : prev), 0);
+ animate(maxX);
+ }
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ const url = inputRef.current?.value || "";
+ vanishAndSubmit();
+ onSubmit && onSubmit(e, url);
+ };
+ return (
+
+ );
+}
diff --git a/Server/src/index.css b/Server/src/index.css
new file mode 100644
index 0000000..2d493a1
--- /dev/null
+++ b/Server/src/index.css
@@ -0,0 +1,43 @@
+@import "tailwindcss";
+
+:root {
+ --bg: #0b0d12;
+ --panel: #121624;
+ --panel2: #0f1320;
+ --text: #e8eaf1;
+ --muted: #9aa3b2;
+ --line: #22283a;
+ --accent: #6aa6ff;
+ --good: #4ade80;
+ --warn: #fbbf24;
+ --bad: #f87171;
+ --shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
+ --radius: 18px;
+}
+
+/* Hide scrollbar but keep scrolling functionality */
+.hide-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+}
+
+.hide-scrollbar::-webkit-scrollbar {
+ display: none; /* Chrome, Safari and Opera */
+}
+
+@theme inline {
+ --animate-cell-ripple: cell-ripple var(--duration, 200ms) ease-out none 1
+ var(--delay, 0ms);
+
+ @keyframes cell-ripple {
+ 0% {
+ opacity: 0.4;
+ }
+ 50% {
+ opacity: 0.8;
+ }
+ 100% {
+ opacity: 0.4;
+ }
+ }
+}
diff --git a/Server/src/lib/appwrite.js b/Server/src/lib/appwrite.js
new file mode 100644
index 0000000..753a482
--- /dev/null
+++ b/Server/src/lib/appwrite.js
@@ -0,0 +1,59 @@
+/**
+ * Zentrales Appwrite Client Setup
+ * Stellt Client, Account, Databases Instanzen und Helper-Funktionen bereit
+ */
+
+import { Client, Account, Databases } from "appwrite";
+
+// Konfiguration aus Environment-Variablen oder Defaults
+const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || "https://appwrite.webklar.com/v1";
+const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || "696b82bb0036d2e547ad";
+const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || "eship-db";
+const usersCollectionId = import.meta.env.VITE_APPWRITE_USERS_COLLECTION_ID || "users";
+const accountsCollectionId = import.meta.env.VITE_APPWRITE_ACCOUNTS_COLLECTION_ID || "accounts";
+
+// Client initialisieren
+const client = new Client()
+ .setEndpoint(endpoint)
+ .setProject(projectId);
+
+// Service-Instanzen
+const account = new Account(client);
+const databases = new Databases(client);
+
+/**
+ * Gibt den aktuell eingeloggten User zurück
+ * @returns {Promise