Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 791a4fcb70 | |||
| 28d1d164f1 | |||
| 1097a5eba3 | |||
| e3083304d0 | |||
| 451264106a | |||
| 96e2fcda49 | |||
| 73cad3a153 | |||
| 5f8b08e02c |
+28
-39
@@ -1,20 +1,36 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- Build the Rust backend ----------
|
||||
FROM rust:1.94-bookworm AS backend-build
|
||||
|
||||
# cargo-chef caches the compiled dependency graph in its own layer, so a
|
||||
# source-only change recompiles just our crate instead of every dependency from
|
||||
# scratch. The recipe is derived solely from Cargo.toml/Cargo.lock, so the
|
||||
# expensive `cook` layer is reused until the dependency set changes.
|
||||
# https://github.com/LukeMathWalker/cargo-chef
|
||||
FROM rust:1.94-bookworm AS chef
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libsqlite3-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& cargo install --locked cargo-chef
|
||||
|
||||
# Distill the dependency recipe. Cheap, and re-runs on any source change, but
|
||||
# yields a stable recipe.json as long as dependencies are unchanged — which is
|
||||
# what keeps the cook layer below cached.
|
||||
FROM chef AS planner
|
||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
COPY backend/src ./src
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
# Build (and cache) dependencies from the recipe, then compile the application.
|
||||
# Must share the chef stage's Rust version for the cached layer to apply.
|
||||
FROM chef AS backend-build
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
COPY backend/Cargo.toml backend/Cargo.lock ./
|
||||
COPY backend/src ./src
|
||||
COPY backend/migrations ./migrations
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
# ---------- Build the frontend with placeholder config ----------
|
||||
@@ -35,7 +51,8 @@ COPY frontend ./
|
||||
|
||||
ENV VITE_API_URL=__VITE_API_URL__ \
|
||||
VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \
|
||||
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__
|
||||
VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__ \
|
||||
VITE_PLATFORM_LOGO=__VITE_PLATFORM_LOGO__
|
||||
RUN bun run build
|
||||
|
||||
# ---------- Runtime ----------
|
||||
@@ -54,42 +71,14 @@ WORKDIR /app
|
||||
|
||||
COPY --from=backend-build /app/target/release/backend /app/backend
|
||||
COPY --from=frontend-build /app/frontend/dist /app/dist
|
||||
COPY --chmod=0755 entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
# Single entrypoint: substitute the real config into the prebuilt bundle, then
|
||||
# run both processes and exit (so the orchestrator restarts us) if either dies.
|
||||
RUN cat > /app/entrypoint.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
RUN mkdir -p /app/data && chown -R node:node /app/dist /app/data
|
||||
|
||||
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
|
||||
VITE_API_URL="${SERVER_URL:-}"
|
||||
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
|
||||
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
|
||||
USER node:node
|
||||
|
||||
# Escape characters that are special in a sed replacement.
|
||||
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
|
||||
|
||||
echo "Applying runtime configuration to the frontend bundle..."
|
||||
while IFS= read -r -d '' f; do
|
||||
sed -i \
|
||||
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
|
||||
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
|
||||
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
|
||||
"$f"
|
||||
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
|
||||
|
||||
echo "Starting backend (:2892) and frontend (:3000)..."
|
||||
/app/backend &
|
||||
backend_pid=$!
|
||||
serve -s /app/dist -l 3000 &
|
||||
serve_pid=$!
|
||||
|
||||
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
|
||||
|
||||
# Exit as soon as either process exits.
|
||||
wait -n
|
||||
EOF
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
ENV SERVER_PORT=2892 \
|
||||
DATABASE_URL=sqlite:///app/data/caravel.db
|
||||
|
||||
EXPOSE 2892 3000
|
||||
|
||||
|
||||
@@ -17,12 +17,11 @@ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v my-caravel-data:/app/data \
|
||||
-e PLATFORM_NAME=Caravel \
|
||||
-e PLATFORM_LOGO=/caravel.png \
|
||||
-e RELAY_DOMAIN=example.com \
|
||||
-e APP_URL=https://example.com \
|
||||
-e ZOOID_API_URL=http://zooid:3334 \
|
||||
-e DATABASE_URL=sqlite://data/caravel.db \
|
||||
-e SERVER_URL=https://api.example.com \
|
||||
-e SERVER_PORT=2892 \
|
||||
-e SERVER_ADMIN_PUBKEYS=<your-hex-pubkey> \
|
||||
-e SERVER_ALLOW_ORIGINS=https://example.com \
|
||||
-e ROBOT_SECRET=<hex-nostr-secret-key> \
|
||||
|
||||
+2
-2
@@ -43,11 +43,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev
|
||||
| Variable | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly |
|
||||
| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) |
|
||||
| `SERVER_PORT` | API bind port; the server binds `0.0.0.0` (all interfaces) |
|
||||
| `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) |
|
||||
| `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins |
|
||||
| `APP_URL` | Frontend base URL; used to build links in DMs and invoices |
|
||||
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` |
|
||||
| `DATABASE_URL` | SQLite URL; relative `sqlite://` paths resolve under the compile-time crate dir (`backend/` in dev, `/app` in the Docker image) |
|
||||
|
||||
**Robot identity**
|
||||
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ async fn main() -> Result<()> {
|
||||
billing.start().await;
|
||||
});
|
||||
|
||||
let url = format!("127.0.0.1:{}", env::get().server_port);
|
||||
let url = format!("0.0.0.0:{}", env::get().server_port);
|
||||
let listener = tokio::net::TcpListener::bind(url).await?;
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Substitute the real config into the prebuilt frontend bundle, then run the
|
||||
# backend and static server, exiting (so the orchestrator restarts us) if either
|
||||
# process dies.
|
||||
set -euo pipefail
|
||||
|
||||
# Map the provided runtime variables onto the frontend's VITE_* placeholders.
|
||||
VITE_API_URL="${SERVER_URL:-}"
|
||||
VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}"
|
||||
VITE_PLATFORM_NAME="${PLATFORM_NAME:-}"
|
||||
VITE_PLATFORM_LOGO="${PLATFORM_LOGO:-/caravel.png}"
|
||||
|
||||
# Escape characters that are special in a sed replacement.
|
||||
esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; }
|
||||
|
||||
echo "Applying runtime configuration to the frontend bundle..."
|
||||
while IFS= read -r -d '' f; do
|
||||
sed -i \
|
||||
-e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \
|
||||
-e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \
|
||||
-e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \
|
||||
-e "s|__VITE_PLATFORM_LOGO__|$(esc "$VITE_PLATFORM_LOGO")|g" \
|
||||
"$f"
|
||||
done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0)
|
||||
|
||||
echo "Starting backend (:2892) and frontend (:3000)..."
|
||||
/app/backend &
|
||||
backend_pid=$!
|
||||
serve -L -s /app/dist -l 3000 &
|
||||
serve_pid=$!
|
||||
|
||||
trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT
|
||||
|
||||
# Exit as soon as either process exits.
|
||||
wait -n
|
||||
@@ -6,3 +6,6 @@ VITE_RELAY_DOMAIN=spaces.coracle.social
|
||||
|
||||
# Platform display name shown in UI
|
||||
VITE_PLATFORM_NAME=Caravel
|
||||
|
||||
# Platform logo shown in UI (path under public/, or an absolute URL)
|
||||
VITE_PLATFORM_LOGO=/caravel.png
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { A, useLocation } from "@solidjs/router"
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { useProfileMetadata, useProfilePicture, useTenantRelays, type Relay } from "@/lib/hooks"
|
||||
import { account, identity } from "@/lib/state"
|
||||
import { account, identity, PLATFORM_LOGO, PLATFORM_NAME } from "@/lib/state"
|
||||
import { fuzzySearch } from "@/lib/search"
|
||||
import { RELAY_DOMAIN } from "@/lib/subdomain"
|
||||
import serverIcon from "@/assets/server.svg"
|
||||
@@ -26,6 +26,14 @@ function RelayIcon() {
|
||||
return <img src={serverIcon} alt="" aria-hidden="true" class="h-5 w-5" />
|
||||
}
|
||||
|
||||
function AdminIcon() {
|
||||
return (
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AppShell(props: { children?: any }) {
|
||||
const location = useLocation()
|
||||
const picture = useProfilePicture(() => account()?.pubkey)
|
||||
@@ -33,6 +41,7 @@ export default function AppShell(props: { children?: any }) {
|
||||
const [tenantRelays] = useTenantRelays()
|
||||
const [searchOpen, setSearchOpen] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
const [adminOpen, setAdminOpen] = createSignal(false)
|
||||
|
||||
const username = createMemo(() => metadata()?.name || metadata()?.display_name || shortenPubkey(account()?.pubkey))
|
||||
const nip05 = createMemo(() => metadata()?.nip05)
|
||||
@@ -57,15 +66,28 @@ export default function AppShell(props: { children?: any }) {
|
||||
: "block rounded-lg px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white"
|
||||
}
|
||||
|
||||
const mobileNavItemClass = (href: string) => {
|
||||
const active = location.pathname === href || location.pathname.startsWith(`${href}/`)
|
||||
return active
|
||||
? "block rounded-lg bg-gray-100 px-3 py-3 text-sm font-medium text-gray-900"
|
||||
: "block rounded-lg px-3 py-3 text-sm text-gray-700 hover:bg-gray-100"
|
||||
}
|
||||
|
||||
const openSearchModal = () => setSearchOpen(true)
|
||||
const closeSearchModal = () => {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery("")
|
||||
}
|
||||
const openAdminModal = () => setAdminOpen(true)
|
||||
const closeAdminModal = () => setAdminOpen(false)
|
||||
|
||||
return (
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<aside class="hidden md:flex fixed inset-y-0 left-0 w-[260px] bg-slate-900 text-white flex-col z-10">
|
||||
<A href="/" class="flex items-center gap-3 border-b border-white/15 px-7 py-5 text-lg font-bold text-white hover:bg-white/10">
|
||||
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="h-7 w-7 rounded" />
|
||||
<span class="truncate">{PLATFORM_NAME}</span>
|
||||
</A>
|
||||
<div class="flex-1 px-4 py-6">
|
||||
<h2 class="px-3 text-xs font-semibold uppercase tracking-wider text-white/60">Resources</h2>
|
||||
<ul class="mt-2 space-y-1">
|
||||
@@ -111,6 +133,7 @@ export default function AppShell(props: { children?: any }) {
|
||||
class="fixed inset-x-0 bottom-0 z-20 border-t border-gray-200 bg-white md:hidden"
|
||||
onClick={() => {
|
||||
if (searchOpen()) closeSearchModal()
|
||||
if (adminOpen()) closeAdminModal()
|
||||
}}
|
||||
>
|
||||
<div class="flex h-16 items-center justify-between px-6">
|
||||
@@ -129,6 +152,19 @@ export default function AppShell(props: { children?: any }) {
|
||||
<A href="/relays" aria-label="Relays" class="rounded-lg p-2 text-gray-700 hover:bg-gray-100">
|
||||
<RelayIcon />
|
||||
</A>
|
||||
<Show when={identity()?.is_admin}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Administration"
|
||||
class="rounded-lg p-2 text-gray-700 hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openAdminModal()
|
||||
}}
|
||||
>
|
||||
<AdminIcon />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<A href="/account" aria-label="Account settings" class="rounded-full">
|
||||
<Show
|
||||
@@ -198,6 +234,35 @@ export default function AppShell(props: { children?: any }) {
|
||||
</Show>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={adminOpen()}
|
||||
onClose={closeAdminModal}
|
||||
wrapperClass="fixed inset-x-0 top-0 bottom-16 z-30 flex items-end bg-black/40 p-0 md:hidden"
|
||||
panelClass="w-full overflow-hidden rounded-t-2xl bg-white"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-gray-200 p-4">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500">Administration</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-3 py-2 text-sm text-gray-600 hover:bg-gray-100"
|
||||
onClick={closeAdminModal}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<ul class="space-y-1 p-4">
|
||||
<For each={adminResources}>
|
||||
{(item) => (
|
||||
<li>
|
||||
<A href={item.href} class={mobileNavItemClass(item.href)} onClick={closeAdminModal}>
|
||||
{item.label}
|
||||
</A>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function RelayCardHeader(props: RelayCardHeaderProps) {
|
||||
<StatusBadge status={r().status} />
|
||||
</div>
|
||||
<a
|
||||
href={`wss://${r().subdomain}.${RELAY_DOMAIN}`}
|
||||
href={`https://${r().subdomain}.${RELAY_DOMAIN}`}
|
||||
class="text-sm text-blue-600 hover:underline break-all"
|
||||
>
|
||||
wss://{r().subdomain}.{RELAY_DOMAIN}
|
||||
|
||||
Vendored
+1
@@ -24,6 +24,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_RELAY_DOMAIN: string
|
||||
readonly VITE_PLATFORM_NAME?: string
|
||||
readonly VITE_PLATFORM_LOGO?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -27,6 +27,7 @@ export type EventSigner = {
|
||||
}
|
||||
|
||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
|
||||
export const PLATFORM_LOGO = import.meta.env.VITE_PLATFORM_LOGO
|
||||
|
||||
export const eventStore = new EventStore()
|
||||
export const pool = new RelayPool()
|
||||
|
||||
@@ -10,9 +10,10 @@ import PaymentSetup from "@/components/PaymentSetup"
|
||||
import Login from "@/views/Login"
|
||||
import { createRelayForActiveTenant, resolvePostPaidFlow } from "@/lib/hooks"
|
||||
import type { Invoice } from "@/lib/api"
|
||||
import { account, refetchBilling, setToastMessage } from "@/lib/state"
|
||||
import { account, PLATFORM_LOGO, PLATFORM_NAME, refetchBilling, setToastMessage } from "@/lib/state"
|
||||
import FlotillaLogo from "@/assets/flotilla-logo.svg"
|
||||
import NostordLogo from "@/assets/nostord-logo.svg"
|
||||
import ChachiLogo from "@/assets/chachi-logo.svg"
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate()
|
||||
@@ -100,8 +101,8 @@ export default function Home() {
|
||||
<nav class="sticky top-0 z-20 bg-white/80 backdrop-blur border-b border-gray-100">
|
||||
<div class="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold text-gray-900">
|
||||
<img src="/caravel.png" alt="Caravel" class="w-7 h-7 rounded" />
|
||||
Caravel
|
||||
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-7 h-7 rounded" />
|
||||
{PLATFORM_NAME}
|
||||
</div>
|
||||
<Show
|
||||
when={account()}
|
||||
@@ -177,7 +178,7 @@ export default function Home() {
|
||||
<div class="max-w-5xl mx-auto px-6 py-20">
|
||||
<h2 class="text-3xl font-bold text-center text-gray-900 mb-4">Everything you need</h2>
|
||||
<p class="text-center text-gray-500 mb-14 max-w-xl mx-auto">
|
||||
Caravel takes care of the infrastructure so you can focus on building your community.
|
||||
{PLATFORM_NAME} takes care of the infrastructure so you can focus on building your community.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
@@ -340,6 +341,44 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{/* Chachi */}
|
||||
<a
|
||||
href="https://chachi.chat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-purple-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
|
||||
<p class="text-xs text-gray-400">chachi.chat</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-300 group-hover:text-purple-400 transition-colors mt-1">
|
||||
<ExternalLinkIcon />
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 leading-relaxed">
|
||||
A group chat app built on top of Nostr. Chachi makes it easy for your community
|
||||
to have real-time conversations, all flowing through your own relay.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{["Real-time group messaging", "Bring your own relay", "No accounts — just your Nostr key"].map(f => (
|
||||
<div class="flex items-start gap-2 text-sm text-gray-600">
|
||||
<CheckIcon />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="mt-auto pt-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-purple-600">
|
||||
Visit chachi.chat <ExternalLinkIcon />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -361,7 +400,7 @@ export default function Home() {
|
||||
<div class="relative max-w-5xl mx-auto px-6 py-28 text-center">
|
||||
<h2 class="text-4xl font-extrabold text-gray-900 mb-4">Ready to launch your relay?</h2>
|
||||
<p class="text-gray-500 mb-10 max-w-lg mx-auto text-lg">
|
||||
Join communities already running on Caravel. Set up in minutes.
|
||||
Join communities already running on {PLATFORM_NAME}. Set up in minutes.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -378,14 +417,14 @@ export default function Home() {
|
||||
<footer class="border-t border-gray-100 bg-gray-50">
|
||||
<div class="max-w-5xl mx-auto px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm text-gray-400">
|
||||
<div class="flex items-center gap-2 font-semibold text-gray-500">
|
||||
<img src="/caravel.png" alt="Caravel" class="w-5 h-5 rounded" />
|
||||
Caravel
|
||||
<img src={PLATFORM_LOGO} alt={PLATFORM_NAME} class="w-5 h-5 rounded" />
|
||||
{PLATFORM_NAME}
|
||||
</div>
|
||||
<p>© {new Date().getFullYear()} Caravel. Built on Nostr.</p>
|
||||
<p>© {new Date().getFullYear()} {PLATFORM_NAME}. Built on Nostr.</p>
|
||||
<div class="flex gap-4">
|
||||
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
|
||||
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
|
||||
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
|
||||
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user