Beta2Main (#1)

* Beta2Main

* Add Umami
This commit is contained in:
mroxso
2024-08-04 22:29:29 +02:00
committed by GitHub
parent 4783d519d3
commit 8bf7d91a87
27 changed files with 637 additions and 48 deletions

View File

@@ -85,5 +85,5 @@ jobs:
ssh -o StrictHostKeyChecking=no ${{ env.MAIN_HOST_USERNAME }}@${{ env.MAIN_HOST }} '
cd lumina &&
git pull origin main &&
docker compose up -d
docker compose up -d --build
'

88
.github/workflows/cd_beta.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Continous Deplyoment Beta
on:
workflow_dispatch:
push:
branches:
- beta
env:
REGISTRY_NAME: ghcr.io
IMAGE_NAME: lumina
BETA_HOST: ${{ secrets.BETA_HOST }}
BETA_HOST_USERNAME: ${{ secrets.BETA_HOST_USERNAME }}
jobs:
# ci:
# name: CI
# uses: ./.github/workflows/ci.yml
build_and_push:
# needs: [ci]
name: Build and Push
runs-on: ubuntu-latest
steps:
- name: Check out the repo 🛎️
uses: actions/checkout@v4
- name: Set up QEMU 🐳
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx 🐳
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry 🎫
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Collecting Metadata 🏷️
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_NAME }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
- name: Building And Pushing Image 🚀
id: docker_build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Image digest 📋
run: echo ${{ steps.docker_build.outputs.digest }}
deploy:
needs: [build_and_push]
name: Deployment
runs-on: ubuntu-latest
steps:
- name: Checkout code 🛎️
uses: actions/checkout@v4
- name: Setup SSH 🔑
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ env.BETA_HOST }} >> ~/.ssh/known_hosts
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy 🚀
run: |
ssh -o StrictHostKeyChecking=no ${{ env.BETA_HOST_USERNAME }}@${{ env.BETA_HOST }} '
cd lumina &&
git pull origin beta &&
docker compose up -d --build
'

View File

@@ -1,19 +1,66 @@
FROM node:lts-alpine3.20 as builder
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY lumina/package.json lumina/package-lock.json ./
RUN npm ci
COPY ./lumina/ .
RUN npm run build
# Install dependencies based on the preferred package manager
COPY lumina/package.json lumina/yarn.lock* lumina/package-lock.json* lumina/pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
FROM node:lts-alpine3.20 as runner
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=builder /app/package.json .
COPY --from=builder /app/package-lock.json .
COPY --from=builder /app/next.config.mjs ./
COPY --from=deps /app/node_modules ./node_modules
COPY lumina/. .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
ENV NEXT_TELEMETRY_DISABLED 1
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
RUN npm i
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

View File

@@ -4,4 +4,20 @@ A social media for images and pictures 📸
## Docker
### Quickstart
`docker run --rm -it -p 3000:3000 ghcr.io/lumina-rocks/lumina:main`
```
docker run --rm -it -p 3000:3000 ghcr.io/lumina-rocks/lumina:main
```
or with Docker Compose
```
docker compose up -d
```
## Umami
Umami is disabled by default.
To enable Umami edit the `.env` file in the `lumina` directory.
Then build the Docker Image again and restart the container.
```
docker compose up -d --build
```

View File

@@ -2,6 +2,6 @@ version: '3'
services:
lumina:
build: .
# image: ghcr.io/mroxso/lumina-rocks-website:latest
# image: ghcr.io/lumina-rocks/lumina:latest
ports:
- "8080:3000"

3
lumina/.env Normal file
View File

@@ -0,0 +1,3 @@
NEXT_PUBLIC_ENABLE_UMAMI=false
NEXT_PUBLIC_UMAMI_WEBSITE_ID=YOUR_CODE_HERE
NEXT_PUBLIC_UMAMI_URL=YOUR_URL_HERE

2
lumina/.gitignore vendored
View File

@@ -1,5 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
/.env
# dependencies
/node_modules
/.pnp

View File

@@ -7,6 +7,8 @@ import { TopNavigation } from "@/components/headerComponents/TopNavigation";
import BottomBar from "@/components/BottomBar";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster"
import Script from "next/script";
import Umami from "@/components/Umami";
export const metadata: Metadata = {
title: "LUMINA",
@@ -36,6 +38,7 @@ export default function RootLayout({
>
<TopNavigation />
<Toaster />
<Umami />
<div className="main-content pb-14">
{children}
</div>

View File

@@ -9,6 +9,7 @@ import { SectionIcon, GridIcon } from '@radix-ui/react-icons'
import ProfileQuickViewFeed from "@/components/ProfileQuickViewFeed";
import ProfileTextFeed from "@/components/ProfileTextFeed";
import { NostrProvider } from "nostr-react";
import ProfileGalleryViewFeed from "@/components/ProfileGalleryViewFeed";
export default function ProfilePage() {
@@ -24,7 +25,7 @@ export default function ProfilePage() {
const relayUrls = [
"wss://relay.lumina.rocks",
];
return (
<>
<NostrProvider relayUrls={relayUrls} debug={false}>
@@ -37,6 +38,7 @@ export default function ProfilePage() {
<TabsTrigger value="QuickView"><GridIcon /></TabsTrigger>
<TabsTrigger value="ProfileFeed"><SectionIcon /></TabsTrigger>
<TabsTrigger value="ProfileTextFeed">Notes</TabsTrigger>
{/* <TabsTrigger value="Gallery">Gallery</TabsTrigger> */}
</TabsList>
<TabsContent value="QuickView">
<ProfileQuickViewFeed pubkey={pubkey.toString()} />
@@ -47,6 +49,9 @@ export default function ProfilePage() {
<TabsContent value="ProfileTextFeed">
<ProfileTextFeed pubkey={pubkey.toString()} />
</TabsContent>
{/* <TabsContent value="Gallery">
<ProfileGalleryViewFeed pubkey={pubkey.toString()} />
</TabsContent> */}
</Tabs>
</div>
</NostrProvider>

View File

@@ -0,0 +1,46 @@
'use client';
import Head from "next/head";
import ProfileInfoCard from "@/components/ProfileInfoCard";
import ProfileFeed from "@/components/ProfileFeed";
import { useParams } from 'next/navigation'
import { Event, NostrEvent, finalizeEvent, nip19 } from "nostr-tools";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { SectionIcon, GridIcon } from '@radix-ui/react-icons'
import TagFeed from "@/components/TagFeed";
import { NostrProvider, useNostr } from "nostr-react";
import { FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import UploadComponent from "@/components/UploadComponent";
export default function UploadPage() {
// check if pubkey contains "npub"
// if so, then we need to convert it to a pubkey
// if (pubkey.includes("npub")) {
// // convert npub to pubkey
// pubkey = nip19.decode(pubkey.toString()).data.toString()
// }
const relayUrls = [
"wss://relay.lumina.rocks",
];
return (
<>
<NostrProvider relayUrls={relayUrls} debug={false}>
<Head>
<title>LUMINA.rocks</title>
<meta name="description" content="Yet another nostr web ui" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="py-6 px-6">
<UploadComponent />
</div>
</NostrProvider>
</>
);
}

View File

@@ -1,27 +1,38 @@
"use client";
/**
* v0 by Vercel.
* @see https://v0.dev/t/mwaJmHMv0vd
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
*/
import { BellIcon, GlobeIcon, HomeIcon, RowsIcon } from "@radix-ui/react-icons"
import { BellIcon, GlobeIcon, HomeIcon, RowsIcon, UploadIcon } from "@radix-ui/react-icons"
import Link from "next/link"
import { JSX, SVGProps, useEffect, useState } from "react"
import { FormEvent, JSX, SVGProps, useEffect, useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { useRouter, usePathname } from 'next/navigation'
import { SearchIcon } from "lucide-react";
import { SearchIcon, Upload } from "lucide-react";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { useNostr } from "nostr-react";
export default function BottomBar() {
const router = useRouter();
const [pubkey, setPubkey] = useState<null | string>(null);
const pathname = usePathname();
useEffect(() => {
return setPubkey(window.localStorage.getItem('pubkey') ?? null);
if (typeof window !== 'undefined') {
setPubkey(window.localStorage.getItem('pubkey') ?? null);
}
}, []);
const pathname = usePathname();
if (typeof window === 'undefined') return null;
const isActive = (path: string, currentPath: string) => currentPath === path ? 'text-purple-500' : '';
return (
@@ -36,10 +47,12 @@ export default function BottomBar() {
<span className="sr-only">Follower Feed</span>
</Link>
)}
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/global', pathname)}`} href="/global">
<GlobeIcon className={`h-6 w-6`} />
<span className="sr-only">Global</span>
</Link>
{/* {pubkey && window.localStorage.getItem('loginType') != 'readOnly_npub' && (
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/upload', pathname)}`} href="/upload">
<UploadIcon className={`h-6 w-6`} />
<span className="sr-only">Upload</span>
</Link>
)} */}
<Link className={`flex flex-col items-center justify-center w-full text-xs gap-1 px-4 ${isActive('/search', pathname)}`} href="/search">
<SearchIcon className={`h-6 w-6`} />
<span className="sr-only">Search</span>

View File

@@ -43,8 +43,8 @@ const NoteCard: React.FC<CommentCardProps> = ({ pubkey, text, eventId, tags, eve
});
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif)/g, '');
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g, '');
const createdAt = new Date(event.created_at * 1000);
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;

View File

@@ -32,7 +32,7 @@ const FollowerFeed: React.FC<FollowerFeedProps> = ({ pubkey }) => {
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
// filter events with regex that checks for png, jpg, or gif
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|jpeg)/g)?.[0]);
// now filter all events with a tag[0] == t and tag[1] == nsfw
filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));

View File

@@ -31,7 +31,7 @@ const FollowerQuickViewFeed: React.FC<FollowerQuickViewFeedProps> = ({ pubkey })
},
});
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
// filter out all replies (tag[0] == e)
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { useProfile } from "nostr-react";
import {
nip19,
} from "nostr-tools";
import {
Card,
SmallCardContent,
} from "@/components/ui/card"
import Image from 'next/image';
import Link from 'next/link';
import { PlayIcon, StackIcon, VideoIcon } from '@radix-ui/react-icons';
interface GalleryCardProps {
pubkey: string;
eventId: string;
imageUrl: string;
linkToNote: boolean;
}
const GalleryCard: React.FC<GalleryCardProps> = ({ pubkey, eventId, imageUrl, linkToNote }) => {
const { data: userData } = useProfile({
pubkey,
});
const encodedNoteId = nip19.noteEncode(eventId);
const card = (
<Card>
<SmallCardContent>
<div>
<div className='d-flex justify-content-center align-items-center'>
<div style={{ position: 'relative' }}>
<img src={imageUrl} className='rounded lg:rounded-lg' style={{ maxWidth: '100%', maxHeight: '75vh', objectFit: 'contain', margin: 'auto' }} alt={eventId} />
</div>
</div>
</div>
</SmallCardContent>
</Card>
);
return (
<>
{linkToNote ? (
<Link href={`/note/${encodedNoteId}`}>
{card}
</Link>
) : (
card
)}
</>
);
}
export default GalleryCard;

View File

@@ -16,7 +16,7 @@ const GlobalFeed: React.FC = () => {
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
// filter events with regex that checks for png, jpg, or gif
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
// now filter all events with a tag[0] == t and tag[1] == nsfw
// filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));

View File

@@ -49,9 +49,9 @@ const NoteCard: React.FC<NoteCardProps> = ({ pubkey, text, eventId, tags, event,
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
// text = text.replaceAll('\n', '<br />');
text = text.replaceAll('\n', ' ');
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
const videoSrc = text.match(/https?:\/\/[^ ]*\.(mp4|webm|mov)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g, '');
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g, '');
const createdAt = new Date(event.created_at * 1000);
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;

View File

@@ -20,7 +20,7 @@ const ProfileFeed: React.FC<ProfileFeedProps> = ({ pubkey }) => {
},
});
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g)?.[0]);
// filter out all replies (tag[0] == e)
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));

View File

@@ -0,0 +1,61 @@
import { useRef } from "react";
import { useNostrEvents } from "nostr-react";
import { Skeleton } from "@/components/ui/skeleton";
import GalleryCard from "./GalleryCard";
interface ProfileGalleryViewFeedProps {
pubkey: string;
}
const ProfileGalleryViewFeed: React.FC<ProfileGalleryViewFeedProps> = ({ pubkey }) => {
const now = useRef(new Date()); // Make sure current time isn't re-rendered
const { isLoading, events } = useNostrEvents({
filter: {
authors: [pubkey],
limit: 1,
kinds: [10011],
},
});
const imagesAndIds = events.map((event) => {
return {
id: event.tags.filter((tag) => tag[0] === 'G').map((tag) => tag[1]),
images: event.tags.filter((tag) => tag[0] === 'G').map((tag) => tag[2])
}
});
return (
<>
<div className="grid grid-cols-3 gap-2">
{imagesAndIds.length === 0 && isLoading ? (
<>
<div>
<Skeleton className="h-[125px] rounded-xl" />
</div>
<div>
<Skeleton className="h-[125px] rounded-xl" />
</div>
<div>
<Skeleton className="h-[125px] rounded-xl" />
</div>
</>
) : (
imagesAndIds.map((galleryEntry) => (
galleryEntry.images.map((imageUrl, index) => (
<GalleryCard
pubkey={pubkey}
key={`${galleryEntry.id[index]}-${index}`}
eventId={galleryEntry.id[index]}
imageUrl={imageUrl}
linkToNote={true}
/>
))
))
)}
</div>
</>
);
}
export default ProfileGalleryViewFeed;

View File

@@ -20,7 +20,7 @@ const ProfileQuickViewFeed: React.FC<ProfileQuickViewFeedProps> = ({ pubkey }) =
},
});
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g)?.[0]);
// filter out all replies (tag[0] == e)
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));

View File

@@ -21,7 +21,7 @@ const ProfileTextFeed: React.FC<ProfileTextFeedProps> = ({ pubkey }) => {
});
// filter out all images since we only want text messages
let filteredEvents = events.filter((event) => !event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
let filteredEvents = events.filter((event) => !event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
// filter out all replies (tag[0] == e)
filteredEvents = filteredEvents.filter((event) => !event.tags.some((tag) => { return tag[0] == 'e' }));

View File

@@ -27,9 +27,9 @@ const QuickViewNoteCard: React.FC<NoteCardProps> = ({ pubkey, text, eventId, tag
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey);
text = text.replaceAll('\n', ' ');
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
const videoSrc = text.match(/https?:\/\/[^ ]*\.(mp4|webm|mov)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov)/g, '');
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|mov|jpeg)/g, '');
const createdAt = new Date(event.created_at * 1000);
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;

View File

@@ -16,7 +16,7 @@ const ReelFeed: React.FC = () => {
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
// filter events with regex that checks for png, jpg, or gif
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g)?.[0]);
// now filter all events with a tag[0] == t and tag[1] == nsfw
// filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));

View File

@@ -21,7 +21,7 @@ const TagFeed: React.FC<TagFeedProps> = ({tag}) => {
// const filteredEvents = events.filter((event) => event.content.includes(".jpg"));
// filter events with regex that checks for png, jpg, or gif
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm)/g)?.[0]);
let filteredEvents = events.filter((event) => event.content.match(/https?:\/\/.*\.(?:png|jpg|gif|mp4|webm|jpeg)/g)?.[0]);
// now filter all events with a tag[0] == t and tag[1] == nsfw
filteredEvents = filteredEvents.filter((event) => event.tags.map((tag) => tag[0] == "t" && tag[1] == "nsfw"));

View File

@@ -41,8 +41,8 @@ const TrendingImage: React.FC<TrendingImageProps> = ({ eventId, pubkey }) => {
const createdAt = events && events.length > 0 ? new Date(events[0].created_at * 1000) : new Date();
const title = userData?.username || userData?.display_name || userData?.name || userData?.npub || npubShortened;
text = text.replaceAll('\n', ' ');
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif)/g, '');
const imageSrc = text.match(/https?:\/\/[^ ]*\.(png|jpg|gif|jpeg)/g);
const textWithoutImage = text.replace(/https?:\/\/.*\.(?:png|jpg|gif|jpeg)/g, '');
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`;
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey;

View File

@@ -0,0 +1,19 @@
import Script from "next/script";
import React from "react";
const Umami = () => {
if(process.env.NEXT_PUBLIC_ENABLE_UMAMI == "true") {
return (
<Script
src={`${process.env.NEXT_PUBLIC_UMAMI_URL}/script.js`}
strategy="afterInteractive"
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
defer
/>
);
} else {
return null;
}
};
export default Umami;

View File

@@ -0,0 +1,231 @@
import { useNostr } from 'nostr-react';
import { finalizeEvent, nip19, NostrEvent } from 'nostr-tools';
import React, { ChangeEvent, FormEvent, useState } from 'react';
import { Button } from './ui/button';
import { Textarea } from './ui/textarea';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { ReloadIcon } from '@radix-ui/react-icons';
import { Label } from './ui/label';
import { Input } from './ui/input';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
const UploadComponent: React.FC = () => {
const { publish } = useNostr();
const { createHash } = require('crypto');
const loginType = typeof window !== 'undefined' ? window.localStorage.getItem('loginType') : null;
const [previewUrl, setPreviewUrl] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
// Optional: Bereinigung alter URLs
return () => URL.revokeObjectURL(url);
}
};
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsLoading(true);
const formData = new FormData(event.currentTarget);
const desc = formData.get('description') as string;
const file = formData.get('file') as File;
let sha256 = '';
let finalNoteContent = desc;
let finalFileUrl = '';
console.log('File:', file);
if (!desc && !file.size) {
alert('Please enter a description and/or upload a file');
setIsLoading(false);
return;
}
// get every hashtag in desc and cut off the # symbol
let hashtags: string[] = desc.match(/#[a-zA-Z0-9]+/g) || [];
if (hashtags) {
hashtags = hashtags.map((hashtag) => hashtag.slice(1));
}
// If file is is preent, upload it to the media server
if (file) {
const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
};
try {
const arrayBuffer = await readFileAsArrayBuffer(file);
const hashBuffer = createHash('sha256').update(Buffer.from(arrayBuffer)).digest();
sha256 = hashBuffer.toString('hex');
const unixNow = () => Math.floor(Date.now() / 1000);
const newExpirationValue = () => (unixNow() + 60 * 5).toString();
const pubkey = window.localStorage.getItem('pubkey');
const createdAt = Math.floor(Date.now() / 1000);
// Create auth event for blossom auth via nostr
let authEvent = {
kind: 24242,
content: desc,
created_at: createdAt,
tags: [
['t', 'upload'],
['x', sha256],
['expiration', newExpirationValue()],
],
};
console.log(authEvent);
// Sign auth event
let authEventSigned = {};
if (loginType === 'extension') {
authEventSigned = await window.nostr.signEvent(authEvent);
} else if (loginType === 'amber') {
// TODO: Sign event with amber
alert('Signing with Amber is not implemented yet, sorry!');
} else if (loginType === 'raw_nsec') {
if (typeof window !== 'undefined') {
let nsecStr = null;
nsecStr = window.localStorage.getItem('nsec');
if (nsecStr != null) {
authEventSigned = finalizeEvent(authEvent, hexToBytes(nsecStr));
}
}
}
console.log(authEventSigned);
// Actually upload the file
await fetch('https://media.lumina.rocks/upload', {
method: 'PUT',
body: file,
headers: { authorization: 'Nostr ' + btoa(JSON.stringify(authEventSigned)) },
}).then(async (res) => {
if (res.ok) {
let responseText = await res.text();
let responseJson = JSON.parse(responseText);
finalFileUrl = responseJson.url;
} else {
alert(await res.text());
}
});
} catch (error) {
console.error('Error reading file:', error);
}
}
let noteTags = hashtags.map((tag) => ['t', tag]);
// If we have a file, add the file url to the note content
// and also to the note tags imeta
// "tags": [
// [
// "imeta",
// "url https://nostr.build/i/my-image.jpg",
// "m image/jpeg",
// "blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$",
// "dim 3024x4032",
// "alt A scenic photo overlooking the coast of Costa Rica",
// "x <sha256 hash as specified in NIP 94>",
// "fallback https://nostrcheck.me/alt1.jpg",
// "fallback https://void.cat/alt1.jpg"
// ]
// ]
if (finalFileUrl) {
// convert file into image
const image = new Image();
image.src = URL.createObjectURL(file);
finalNoteContent = finalFileUrl + ' ' + desc;
noteTags.push(['imeta', 'url ' + finalFileUrl, 'm ' + file.type, 'x ' + sha256, 'ox ' + sha256]);
}
const createdAt = Math.floor(Date.now() / 1000);
// Create the actual note
let noteEvent = {
kind: 1,
content: finalNoteContent,
created_at: createdAt,
tags: noteTags,
};
let signedEvent: NostrEvent | null = null;
// Sign the actual note
if (loginType === 'extension') {
signedEvent = await window.nostr.signEvent(noteEvent);
} else if (loginType === 'amber') {
// TODO: Sign event with amber
alert('Signing with Amber is not implemented yet, sorry!');
} else if (loginType === 'raw_nsec') {
if (typeof window !== 'undefined') {
let nsecStr = null;
nsecStr = window.localStorage.getItem('nsec');
if (nsecStr != null) {
signedEvent = finalizeEvent(noteEvent, hexToBytes(nsecStr));
}
}
}
// If the got a signed event, publish it to nostr
if (signedEvent) {
console.log("final Event: ")
console.log(signedEvent)
publish(signedEvent);
}
// Redirect to the note
setIsLoading(false);
if (signedEvent != null) {
window.location.href = '/note/' + nip19.noteEncode(signedEvent.id);
}
}
return (
<>
<div>
<form className="space-y-4" onSubmit={onSubmit}>
<Textarea name="description" rows={6} placeholder="Your description" id="description" className="w-full"></Textarea>
<Accordion type="single" collapsible>
<AccordionItem value="item-1">
<AccordionTrigger>Image Upload</AccordionTrigger>
<AccordionContent>
<div className="grid w-full max-w-sm items-center gap-1.5">
<Input id="file" name='file' type="file" accept='image/*' onChange={handleFileChange} />
</div>
{previewUrl && <img src={previewUrl} alt="Preview" className="w-full pt-4" />}
</AccordionContent>
</AccordionItem>
</Accordion>
{isLoading ? (
<Button className='w-full' disabled>Uploading.. <ReloadIcon className="m-2 h-4 w-4 animate-spin" /></Button>
) : (
<Button className='w-full'>Upload</Button>
)}
</form>
</div>
</>
);
}
export default UploadComponent;