mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-06 18:31:00 +00:00
Rip out redux for local storage, reowkr hooks, more responsiveness fixes
This commit is contained in:
parent
919f13c88c
commit
7abf1c8882
@ -2,7 +2,7 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
images: {
|
||||||
domains: ['localhost'],
|
domains: ['localhost', 'secure.gravatar.com'],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,15 @@ const HeroBanner = () => {
|
|||||||
const [currentOption, setCurrentOption] = useState(0);
|
const [currentOption, setCurrentOption] = useState(0);
|
||||||
const [isFlipping, setIsFlipping] = useState(false);
|
const [isFlipping, setIsFlipping] = useState(false);
|
||||||
|
|
||||||
|
const getColorClass = (option) => {
|
||||||
|
switch (option) {
|
||||||
|
case 'Bitcoin': return 'text-orange-400';
|
||||||
|
case 'Lightning': return 'text-blue-500';
|
||||||
|
case 'Nostr': return 'text-purple-400';
|
||||||
|
default: return 'text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setIsFlipping(true);
|
setIsFlipping(true);
|
||||||
@ -28,18 +37,20 @@ const HeroBanner = () => {
|
|||||||
width={1920}
|
width={1920}
|
||||||
height={1080}
|
height={1080}
|
||||||
quality={100}
|
quality={100}
|
||||||
|
className='opacity-70'
|
||||||
/>
|
/>
|
||||||
<div className="absolute text-center text-white text-xl">
|
<div className="absolute text-center text-white text-xl h-full flex flex-col justify-evenly">
|
||||||
<p className='text-4xl max-tab:text-xl max-mob:text-xl'>Learn how to code</p>
|
<p className='text-2xl md:text-3xl lg:text-4xl xl:text-5xl'>Learn how to code</p>
|
||||||
<p className='text-4xl pt-4 max-tab:text-xl max-mob:text-xl max-tab:pt-2 max-mob:pt-2'>
|
<p className='text-2xl md:text-3xl lg:text-4xl xl:text-5xl'>
|
||||||
Build{' '}
|
Build{' '}
|
||||||
<span className={`text-4xl max-tab:text-xl max-mob:text-xl inline-block w-40 text-center max-tab:w-24 max-mob:w-24 ${isFlipping ? 'flip-enter-active' : ''}`}>
|
<span className={`inline-block w-[35%] ${isFlipping ? 'flip-enter-active' : ''} ${getColorClass(options[currentOption])}`}>
|
||||||
{options[currentOption]}
|
{options[currentOption]}
|
||||||
</span>
|
</span>
|
||||||
{' '}apps
|
{' '}apps
|
||||||
</p>
|
</p>
|
||||||
<p className='text-4xl pt-4 max-tab:text-xl max-mob:text-xl max-tab:pt-2 max-mob:pt-2'>Become a Bitcoin developer</p>
|
<p className='text-2xl md:text-3xl lg:text-4xl xl:text-5xl'>Become a Bitcoin developer</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, use } from 'react';
|
||||||
import { Carousel } from 'primereact/carousel';
|
import { Carousel } from 'primereact/carousel';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { parseEvent } from '@/utils/nostr';
|
import { parseEvent } from '@/utils/nostr';
|
||||||
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
||||||
|
import { useNostr } from '@/hooks/useNostr';
|
||||||
|
|
||||||
const responsiveOptions = [
|
const responsiveOptions = [
|
||||||
{
|
{
|
||||||
@ -27,13 +27,20 @@ const responsiveOptions = [
|
|||||||
|
|
||||||
|
|
||||||
export default function CoursesCarousel() {
|
export default function CoursesCarousel() {
|
||||||
const courses = useSelector((state) => state.events.courses);
|
|
||||||
const [processedCourses, setProcessedCourses] = useState([]);
|
const [processedCourses, setProcessedCourses] = useState([]);
|
||||||
|
const [screenWidth, setScreenWidth] = useState(null);
|
||||||
|
const [courses, setCourses] = useState([]);
|
||||||
|
const router = useRouter();
|
||||||
|
const { fetchCourses, events } = useNostr();
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
|
|
||||||
const router = useRouter();
|
useEffect(() => {
|
||||||
|
if (events && events.courses && events.courses.length > 0) {
|
||||||
const [screenWidth, setScreenWidth] = useState(null);
|
setCourses(events.courses);
|
||||||
|
} else {
|
||||||
|
fetchCourses();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update the state to the current window width
|
// Update the state to the current window width
|
||||||
@ -60,7 +67,7 @@ export default function CoursesCarousel() {
|
|||||||
return { width: 344, height: 194 };
|
return { width: 344, height: 194 };
|
||||||
} else {
|
} else {
|
||||||
// Small screens
|
// Small screens
|
||||||
return { width: screenWidth - 50, height: (screenWidth - 50) * (9 / 16) };
|
return { width: screenWidth - 120, height: (screenWidth - 120) * (9 / 16) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -109,7 +116,7 @@ export default function CoursesCarousel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="ml-[6%] mt-4">courses</h2>
|
<h2 className="ml-[6%] mt-4">courses</h2>
|
||||||
<Carousel value={processedCourses} numVisible={2} itemTemplate={courseTemplate} responsiveOptions={responsiveOptions} />
|
<Carousel value={[...processedCourses, ...processedCourses]} numVisible={2} itemTemplate={courseTemplate} responsiveOptions={responsiveOptions} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,36 +1,43 @@
|
|||||||
import React, { useRef } from 'react';
|
"use client";
|
||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import { Menu } from 'primereact/menu';
|
import { Menu } from 'primereact/menu';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
|
||||||
import { setUser } from '@/redux/reducers/userReducer';
|
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
|
import useLocalStorage from '@/hooks/useLocalStorage';
|
||||||
import 'primereact/resources/primereact.min.css';
|
import 'primereact/resources/primereact.min.css';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
import styles from '../navbar.module.css';
|
import styles from '../navbar.module.css';
|
||||||
|
|
||||||
const UserAvatar = () => {
|
const UserAvatar = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const dispatch = useDispatch();
|
const [user, setUser] = useLocalStorage('user', {});
|
||||||
const user = useSelector((state) => state.user.user);
|
const [isClient, setIsClient] = useState(false);
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
|
|
||||||
const menu = useRef(null);
|
const menu = useRef(null);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
window.localStorage.removeItem('pubkey');
|
window.localStorage.removeItem('user');
|
||||||
dispatch(setUser(null));
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
let userAvatar;
|
let userAvatar;
|
||||||
|
|
||||||
if (user && Object.keys(user).length > 0) {
|
useEffect(() => {
|
||||||
|
setIsClient(true); // Component did mount, we're client-side
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If not client, render nothing or a placeholder
|
||||||
|
if (!isClient) {
|
||||||
|
return null; // Or return a loader/spinner/placeholder
|
||||||
|
} else if (user && Object.keys(user).length > 0) {
|
||||||
|
console.log('ahhhhh s:', user);
|
||||||
// User exists, show username or pubkey
|
// User exists, show username or pubkey
|
||||||
const displayName = user.username || user.pubkey;
|
const displayName = user.username || user.pubkey.slice(0, 10) + '...';
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@ -52,15 +59,13 @@ const UserAvatar = () => {
|
|||||||
userAvatar = (
|
userAvatar = (
|
||||||
<>
|
<>
|
||||||
<div onClick={(event) => menu.current.toggle(event)} className='flex flex-row items-center justify-between cursor-pointer hover:opacity-75'>
|
<div onClick={(event) => menu.current.toggle(event)} className='flex flex-row items-center justify-between cursor-pointer hover:opacity-75'>
|
||||||
{user.avatar && (
|
<Image
|
||||||
<Image
|
alt="logo"
|
||||||
alt="logo"
|
src={user?.avatar ? returnImageProxy(user.avatar) : `https://secure.gravatar.com/avatar/${user.pubkey}?s=90&d=identicon`}
|
||||||
src={returnImageProxy(user.avatar)}
|
width={50}
|
||||||
width={50}
|
height={50}
|
||||||
height={50}
|
className={styles.logo}
|
||||||
className={styles.logo}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Menu model={items} popup ref={menu} />
|
<Menu model={items} popup ref={menu} />
|
||||||
</>
|
</>
|
||||||
|
@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Carousel } from 'primereact/carousel';
|
import { Carousel } from 'primereact/carousel';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useSelector } from 'react-redux';
|
import { useNostr } from '@/hooks/useNostr';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { parseEvent } from '@/utils/nostr';
|
import { parseEvent } from '@/utils/nostr';
|
||||||
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
||||||
@ -26,12 +26,20 @@ const responsiveOptions = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function WorkshopsCarousel() {
|
export default function WorkshopsCarousel() {
|
||||||
const workshops = useSelector((state) => state.events.resources);
|
|
||||||
const [processedWorkshops, setProcessedWorkshops] = useState([]);
|
const [processedWorkshops, setProcessedWorkshops] = useState([]);
|
||||||
const [screenWidth, setScreenWidth] = useState(null);
|
const [screenWidth, setScreenWidth] = useState(null);
|
||||||
|
const [workshops, setWorkshops] = useState([]);
|
||||||
|
const router = useRouter();
|
||||||
|
const { fetchWorkshops, events } = useNostr();
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
|
|
||||||
const router = useRouter();
|
useEffect(() => {
|
||||||
|
if (events && events.workshops && events.workshops.length > 0) {
|
||||||
|
setWorkshops(events.workshops);
|
||||||
|
} else {
|
||||||
|
fetchWorkshops();
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Update the state to the current window width
|
// Update the state to the current window width
|
||||||
@ -58,7 +66,7 @@ export default function WorkshopsCarousel() {
|
|||||||
return { width: 344, height: 194 };
|
return { width: 344, height: 194 };
|
||||||
} else {
|
} else {
|
||||||
// Small screens
|
// Small screens
|
||||||
return { width: screenWidth - 50, height: (screenWidth - 50) * (9 / 16) };
|
return { width: screenWidth - 120, height: (screenWidth - 120) * (9 / 16) };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -106,7 +114,7 @@ export default function WorkshopsCarousel() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h2 className="ml-[6%] mt-4">workshops</h2>
|
<h2 className="ml-[6%] mt-4">workshops</h2>
|
||||||
<Carousel value={processedWorkshops} numVisible={2} itemTemplate={workshopTemplate} responsiveOptions={responsiveOptions} />
|
<Carousel value={[...processedWorkshops, ...processedWorkshops]} numVisible={2} itemTemplate={workshopTemplate} responsiveOptions={responsiveOptions} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
35
src/hooks/useLocalStorage.js
Normal file
35
src/hooks/useLocalStorage.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
function useLocalStorage(key, initialValue) {
|
||||||
|
const [storedValue, setStoredValue] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
// Added a check to ensure the item is not only present but also a valid JSON string.
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
// Consider removing or correcting the invalid item in localStorage here.
|
||||||
|
window.localStorage.removeItem(key); // Optional: remove the item that caused the error.
|
||||||
|
return initialValue; // Revert to initial value if parsing fails.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue = value => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useLocalStorage;
|
@ -1,15 +1,12 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import { useNostr } from './useNostr';
|
import { useNostr } from './useNostr';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { setUser } from "@/redux/reducers/userReducer";
|
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||||
import { findKind0Fields } from "@/utils/nostr";
|
import { findKind0Fields } from "@/utils/nostr";
|
||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
|
|
||||||
export const useLogin = () => {
|
export const useLogin = () => {
|
||||||
const dispatch = useDispatch();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const { fetchKind0 } = useNostr();
|
const { fetchKind0 } = useNostr();
|
||||||
@ -17,7 +14,8 @@ export const useLogin = () => {
|
|||||||
// Attempt Auto Login on render
|
// Attempt Auto Login on render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const autoLogin = async () => {
|
const autoLogin = async () => {
|
||||||
const publicKey = window.localStorage.getItem('pubkey');
|
const user = window.localStorage.getItem('user');
|
||||||
|
const publicKey = JSON.parse(user)?.pubkey;
|
||||||
|
|
||||||
if (!publicKey) return;
|
if (!publicKey) return;
|
||||||
|
|
||||||
@ -25,20 +23,26 @@ export const useLogin = () => {
|
|||||||
const response = await axios.get(`/api/users/${publicKey}`);
|
const response = await axios.get(`/api/users/${publicKey}`);
|
||||||
console.log('auto login response:', response);
|
console.log('auto login response:', response);
|
||||||
if (response.status === 200 && response.data) {
|
if (response.status === 200 && response.data) {
|
||||||
dispatch(setUser(response.data));
|
window.localStorage.setItem('user', JSON.stringify(response.data));
|
||||||
} else if (response.status === 204) {
|
} else if (response.status === 204) {
|
||||||
// User not found, create a new user
|
// User not found, create a new user
|
||||||
const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
|
const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
|
||||||
|
|
||||||
console.log('kind0:', kind0);
|
console.log('kind0:', kind0);
|
||||||
const fields = await findKind0Fields(kind0);
|
|
||||||
|
let fields = null;
|
||||||
|
|
||||||
|
if (kind0) {
|
||||||
|
fields = await findKind0Fields(kind0);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = { pubkey: publicKey, ...fields };
|
const payload = { pubkey: publicKey, ...fields };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const createUserResponse = await axios.post(`/api/users`, payload);
|
const createUserResponse = await axios.post(`/api/users`, payload);
|
||||||
console.log('create user response:', createUserResponse);
|
console.log('create user response:', createUserResponse);
|
||||||
if (createUserResponse.status === 201) {
|
if (createUserResponse.status === 201) {
|
||||||
window.localStorage.setItem('pubkey', publicKey);
|
window.localStorage.setItem('user', JSON.stringify(createUserResponse.data));
|
||||||
dispatch(setUser(createUserResponse.data));
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Error creating user:', createUserResponse);
|
console.error('Error creating user:', createUserResponse);
|
||||||
}
|
}
|
||||||
@ -70,9 +74,8 @@ export const useLogin = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/users/${publicKey}`);
|
const response = await axios.get(`/api/users/${publicKey}`);
|
||||||
if (response.status !== 200) throw new Error('User not found');
|
if (response.status !== 200) throw new Error('User not found');
|
||||||
|
;
|
||||||
window.localStorage.setItem('pubkey', publicKey);
|
window.localStorage.setItem('user', JSON.stringify(response.data));
|
||||||
dispatch(setUser(response.data));
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// User not found, create a new user
|
// User not found, create a new user
|
||||||
@ -83,9 +86,7 @@ export const useLogin = () => {
|
|||||||
try {
|
try {
|
||||||
const createUserResponse = await axios.post(`/api/users`, payload);
|
const createUserResponse = await axios.post(`/api/users`, payload);
|
||||||
if (createUserResponse.status === 201) {
|
if (createUserResponse.status === 201) {
|
||||||
;
|
window.localStorage.setItem('user', JSON.stringify(createUserResponse.data));
|
||||||
window.localStorage.setItem('pubkey', publicKey);
|
|
||||||
dispatch(setUser(createUserResponse.data));
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
console.error('Error creating user:', createUserResponse);
|
console.error('Error creating user:', createUserResponse);
|
||||||
@ -95,22 +96,22 @@ export const useLogin = () => {
|
|||||||
showToast('error', 'Error Creating User', 'Failed to create user');
|
showToast('error', 'Error Creating User', 'Failed to create user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, router, showToast, fetchKind0]);
|
}, [router, showToast, fetchKind0]);
|
||||||
|
|
||||||
const anonymousLogin = useCallback(() => {
|
const anonymousLogin = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
const secretKey = generateSecretKey();
|
const secretKey = generateSecretKey();
|
||||||
const publicKey = getPublicKey(secretKey);
|
const publicKey = getPublicKey(secretKey);
|
||||||
|
// need to fix with byteToHex
|
||||||
|
const hexSecretKey = secretKey.toString('hex');
|
||||||
|
|
||||||
dispatch(setUser({ pubkey: publicKey }));
|
window.localStorage.setItem('user', JSON.stringify({ pubkey: publicKey, secretKey: hexSecretKey }));
|
||||||
window.localStorage.setItem('pubkey', publicKey);
|
|
||||||
window.localStorage.setItem('seckey', secretKey);
|
|
||||||
router.push('/');
|
router.push('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during anonymous login:', error);
|
console.error('Error during anonymous login:', error);
|
||||||
showToast('error', 'Error Logging In', 'Failed to log in');
|
showToast('error', 'Error Logging In', 'Failed to log in');
|
||||||
}
|
}
|
||||||
}, [dispatch, router, showToast]);
|
}, [router, showToast]);
|
||||||
|
|
||||||
return { nostrLogin, anonymousLogin };
|
return { nostrLogin, anonymousLogin };
|
||||||
};
|
};
|
||||||
|
@ -1,14 +1,25 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { SimplePool, relayInit, nip19 } from "nostr-tools";
|
import { SimplePool } from "nostr-tools";
|
||||||
import { useDispatch } from "react-redux";
|
|
||||||
import { addResource, addCourse, addWorkshop, addStream } from "@/redux/reducers/eventsReducer";
|
const initialRelays = [
|
||||||
import { initialRelays } from "@/redux/reducers/userReducer";
|
"wss://nos.lol/",
|
||||||
|
"wss://relay.damus.io/",
|
||||||
|
"wss://relay.snort.social/",
|
||||||
|
"wss://relay.nostr.band/",
|
||||||
|
"wss://nostr.mutinywallet.com/",
|
||||||
|
"wss://relay.mutinywallet.com/",
|
||||||
|
"wss://relay.primal.net/"
|
||||||
|
];
|
||||||
|
|
||||||
export const useNostr = () => {
|
export const useNostr = () => {
|
||||||
const [relays, setRelays] = useState(initialRelays);
|
const [relays, setRelays] = useState(initialRelays);
|
||||||
const [relayStatuses, setRelayStatuses] = useState({});
|
const [relayStatuses, setRelayStatuses] = useState({});
|
||||||
|
const [events, setEvents] = useState({
|
||||||
const dispatch = useDispatch();
|
resources: [],
|
||||||
|
workshops: [],
|
||||||
|
courses: [],
|
||||||
|
streams: []
|
||||||
|
});
|
||||||
|
|
||||||
const pool = useRef(new SimplePool({ seenOnEnabled: true }));
|
const pool = useRef(new SimplePool({ seenOnEnabled: true }));
|
||||||
const subscriptions = useRef([]);
|
const subscriptions = useRef([]);
|
||||||
@ -34,6 +45,56 @@ export const useNostr = () => {
|
|||||||
await Promise.all(newRelays.map(relay => pool.current.ensureRelay(relay)));
|
await Promise.all(newRelays.map(relay => pool.current.ensureRelay(relay)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchEvents = async (filter, updateDataField, hasRequiredTags) => {
|
||||||
|
try {
|
||||||
|
const sub = pool.current.subscribeMany(relays, filter, {
|
||||||
|
onevent: (event) => {
|
||||||
|
if (hasRequiredTags(event.tags)) {
|
||||||
|
setEvents(prevData => ({
|
||||||
|
...prevData,
|
||||||
|
[updateDataField]: [...prevData[updateDataField], event]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onerror: (error) => {
|
||||||
|
setError(error);
|
||||||
|
console.error(`Error fetching ${updateDataField}:`, error);
|
||||||
|
},
|
||||||
|
oneose: () => {
|
||||||
|
console.log("Subscription closed");
|
||||||
|
sub.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch resources, workshops, courses, and streams with appropriate filters and update functions
|
||||||
|
const fetchResources = () => {
|
||||||
|
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
||||||
|
const hasRequiredTags = (eventData) => eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "resource");
|
||||||
|
fetchEvents(filter, 'resources', hasRequiredTags);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchWorkshops = () => {
|
||||||
|
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
||||||
|
const hasRequiredTags = (eventData) => eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "resource");
|
||||||
|
fetchEvents(filter, 'workshops', hasRequiredTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchCourses = () => {
|
||||||
|
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
||||||
|
const hasRequiredTags = (eventData) => eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "course");
|
||||||
|
fetchEvents(filter, 'courses', hasRequiredTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStreams = () => {
|
||||||
|
const filter = [{kinds: [30311], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
||||||
|
const hasRequiredTags = (eventData) => eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||||
|
fetchEvents(filter, 'streams', hasRequiredTags);
|
||||||
|
}
|
||||||
|
|
||||||
const fetchKind0 = async (criteria, params) => {
|
const fetchKind0 = async (criteria, params) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const events = [];
|
const events = [];
|
||||||
@ -62,110 +123,6 @@ export const useNostr = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchResources = async () => {
|
|
||||||
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
|
||||||
|
|
||||||
const params = {seenOnEnabled: true};
|
|
||||||
|
|
||||||
const hasRequiredTags = (eventData) => {
|
|
||||||
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "resource");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sub = pool.current.subscribeMany(relays, filter, {
|
|
||||||
...params,
|
|
||||||
onevent: (event) => {
|
|
||||||
if (hasRequiredTags(event.tags)) {
|
|
||||||
dispatch(addResource(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onerror: (error) => {
|
|
||||||
console.error("Error fetching resources:", error);
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
console.log("Subscription closed");
|
|
||||||
sub.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchWorkshops = async () => {
|
|
||||||
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
|
||||||
|
|
||||||
const params = {seenOnEnabled: true};
|
|
||||||
|
|
||||||
const hasRequiredTags = (eventData) => {
|
|
||||||
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "workshop");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sub = pool.current.subscribeMany(relays, filter, {
|
|
||||||
...params,
|
|
||||||
onevent: (event) => {
|
|
||||||
if (hasRequiredTags(event.tags)) {
|
|
||||||
dispatch(addWorkshop(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onerror: (error) => {
|
|
||||||
console.error("Error fetching workshops:", error);
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
console.log("Subscription closed");
|
|
||||||
sub.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCourses = async () => {
|
|
||||||
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
|
||||||
|
|
||||||
const params = {seenOnEnabled: true};
|
|
||||||
|
|
||||||
const hasRequiredTags = (eventData) => {
|
|
||||||
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "course");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sub = pool.current.subscribeMany(relays, filter, {
|
|
||||||
...params,
|
|
||||||
onevent: (event) => {
|
|
||||||
if (hasRequiredTags(event.tags)) {
|
|
||||||
dispatch(addCourse(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onerror: (error) => {
|
|
||||||
console.error("Error fetching courses:", error);
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
console.log("Subscription closed");
|
|
||||||
sub.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchStreams = async () => {
|
|
||||||
const filter = [{kinds: [30311], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
|
|
||||||
|
|
||||||
const params = {seenOnEnabled: true};
|
|
||||||
|
|
||||||
const hasRequiredTags = (eventData) => {
|
|
||||||
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sub = pool.current.subscribeMany(relays, filter, {
|
|
||||||
...params,
|
|
||||||
onevent: (event) => {
|
|
||||||
if (hasRequiredTags(event.tags)) {
|
|
||||||
dispatch(addStream(event));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onerror: (error) => {
|
|
||||||
console.error("Error fetching streams:", error);
|
|
||||||
},
|
|
||||||
oneose: () => {
|
|
||||||
console.log("Subscription closed");
|
|
||||||
sub.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchSingleEvent = async (id) => {
|
const fetchSingleEvent = async (id) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }], {
|
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }], {
|
||||||
@ -181,7 +138,7 @@ export const useNostr = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
const publishEvent = async (event) => {
|
const publishEvent = async (event) => {
|
||||||
try {
|
try {
|
||||||
@ -212,6 +169,8 @@ export const useNostr = () => {
|
|||||||
fetchResources,
|
fetchResources,
|
||||||
fetchCourses,
|
fetchCourses,
|
||||||
fetchWorkshops,
|
fetchWorkshops,
|
||||||
|
fetchStreams,
|
||||||
getRelayStatuses,
|
getRelayStatuses,
|
||||||
|
events
|
||||||
};
|
};
|
||||||
};
|
};
|
@ -1,6 +1,4 @@
|
|||||||
import { PrimeReactProvider } from 'primereact/api';
|
import { PrimeReactProvider } from 'primereact/api';
|
||||||
import { Provider } from "react-redux";
|
|
||||||
import { store } from "@/redux/store";
|
|
||||||
import Navbar from '@/components/navbar/Navbar';
|
import Navbar from '@/components/navbar/Navbar';
|
||||||
import { ToastProvider } from '@/hooks/useToast';
|
import { ToastProvider } from '@/hooks/useToast';
|
||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
@ -12,23 +10,21 @@ export default function MyApp({
|
|||||||
Component, pageProps: { ...pageProps }
|
Component, pageProps: { ...pageProps }
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<PrimeReactProvider>
|
||||||
<PrimeReactProvider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="flex flex-col min-h-screen">
|
<div className="flex flex-col min-h-screen">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{/* <div className='flex'> */}
|
{/* <div className='flex'> */}
|
||||||
{/* <Sidebar /> */}
|
{/* <Sidebar /> */}
|
||||||
{/* <div className='max-w-[100vw] pl-[15vw]'> */}
|
{/* <div className='max-w-[100vw] pl-[15vw]'> */}
|
||||||
<div className='max-w-[100vw]'>
|
<div className='max-w-[100vw]'>
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</div>
|
|
||||||
{/* </div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
{/* </div> */}
|
||||||
</ToastProvider>
|
</div>
|
||||||
</PrimeReactProvider>
|
</Layout>
|
||||||
</Provider>
|
</ToastProvider>
|
||||||
|
</PrimeReactProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -5,9 +5,9 @@ class MyDocument extends Document {
|
|||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Blinker:wght@100;200;300;400;600;700;800;900&family=Poppins&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Blinker:wght@100;200;300;400;600;700;800;900&family=Poppins&display=swap" rel="stylesheet" />
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main />
|
||||||
|
@ -110,7 +110,7 @@ export default function Details() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-bolt"
|
icon="pi pi-bolt"
|
||||||
label="100 sats"
|
label="Zap"
|
||||||
severity="success"
|
severity="success"
|
||||||
outlined
|
outlined
|
||||||
pt={{
|
pt={{
|
||||||
|
@ -1,18 +1,10 @@
|
|||||||
import Head from 'next/head'
|
import Head from 'next/head'
|
||||||
import React, {useCallback, useEffect, useState} from 'react';
|
import React from 'react';
|
||||||
import CoursesCarousel from '@/components/courses/CoursesCarousel'
|
import CoursesCarousel from '@/components/courses/CoursesCarousel'
|
||||||
import WorkshopsCarousel from '@/components/workshops/WorkshopsCarousel'
|
import WorkshopsCarousel from '@/components/workshops/WorkshopsCarousel'
|
||||||
import HeroBanner from '@/components/banner/HeroBanner';
|
import HeroBanner from '@/components/banner/HeroBanner';
|
||||||
import { useNostr } from '@/hooks/useNostr'
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { fetchResources, fetchCourses } = useNostr();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchResources();
|
|
||||||
fetchCourses();
|
|
||||||
}, [fetchResources, fetchCourses]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
@ -5,10 +5,11 @@ import { Menu } from 'primereact/menu';
|
|||||||
import { Column } from 'primereact/column';
|
import { Column } from 'primereact/column';
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useImageProxy } from "@/hooks/useImageProxy";
|
import { useImageProxy } from "@/hooks/useImageProxy";
|
||||||
|
import useLocalStorage from "@/hooks/useLocalStorage";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
const user = useSelector((state) => state.user.user);
|
const [user, setUser] = useLocalStorage('user', {});
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
const menu = useRef(null);
|
const menu = useRef(null);
|
||||||
|
|
||||||
@ -61,22 +62,20 @@ const Profile = () => {
|
|||||||
<div className="max-tab:w-[100vw] max-mob:w-[100vw]">
|
<div className="max-tab:w-[100vw] max-mob:w-[100vw]">
|
||||||
<div className="w-[85vw] flex flex-col justify-center mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
|
<div className="w-[85vw] flex flex-col justify-center mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
|
||||||
<div className="relative flex w-full items-center justify-center">
|
<div className="relative flex w-full items-center justify-center">
|
||||||
{user.avatar && (
|
|
||||||
<Image
|
<Image
|
||||||
alt="user's avatar"
|
alt="user's avatar"
|
||||||
src={returnImageProxy(user.avatar)}
|
src={user?.avatar ? returnImageProxy(user.avatar) : `https://secure.gravatar.com/avatar/${user.pubkey}?s=90&d=identicon`}
|
||||||
width={100}
|
width={100}
|
||||||
height={100}
|
height={100}
|
||||||
className="rounded-full my-4"
|
className="rounded-full my-4"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<i className="pi pi-ellipsis-h absolute right-24 text-2xl my-4 cursor-pointer hover:opacity-75"
|
<i className="pi pi-ellipsis-h absolute right-24 text-2xl my-4 cursor-pointer hover:opacity-75"
|
||||||
onClick={(e) => menu.current.toggle(e)}></i>
|
onClick={(e) => menu.current.toggle(e)}></i>
|
||||||
<Menu model={menuItems} popup ref={menu} />
|
<Menu model={menuItems} popup ref={menu} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<h1 className="text-center text-2xl my-2">{user.username}</h1>
|
<h1 className="text-center text-2xl my-2">{user.username || "Anon"}</h1>
|
||||||
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">{user.pubkey}</h2>
|
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">{user.pubkey}</h2>
|
||||||
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
|
||||||
<h2>Subscription</h2>
|
<h2>Subscription</h2>
|
||||||
|
@ -1,52 +0,0 @@
|
|||||||
import { createSlice } from '@reduxjs/toolkit';
|
|
||||||
|
|
||||||
// Helper function to add single or multiple items without duplicates
|
|
||||||
const addItems = (state, action, key) => {
|
|
||||||
const existingIds = new Set(state[key].map(item => item.id));
|
|
||||||
|
|
||||||
if (Array.isArray(action.payload)) {
|
|
||||||
console.log('action.payload', action.payload);
|
|
||||||
// Filter out duplicates based on the id
|
|
||||||
const uniqueItems = action.payload.filter(item => !existingIds.has(item.id));
|
|
||||||
// If payload is an array, spread it into the existing array without duplicates
|
|
||||||
state[key] = [...state[key], ...action.payload];
|
|
||||||
} else {
|
|
||||||
// If payload is a single item, push it into the array if it's not a duplicate
|
|
||||||
// if (!existingIds.has(action.payload.id)) {
|
|
||||||
state[key].push(action.payload);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const eventsSlice = createSlice({
|
|
||||||
name: 'events',
|
|
||||||
initialState: {
|
|
||||||
resources: [],
|
|
||||||
courses: [],
|
|
||||||
workshops: [],
|
|
||||||
streams: [],
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
addResource: (state, action) => {
|
|
||||||
addItems(state, action, 'resources');
|
|
||||||
},
|
|
||||||
addCourse: (state, action) => {
|
|
||||||
addItems(state, action, 'courses');
|
|
||||||
},
|
|
||||||
addWorkshop: (state, action) => {
|
|
||||||
addItems(state, action, 'workshops');
|
|
||||||
},
|
|
||||||
addStream: (state, action) => {
|
|
||||||
addItems(state, action, 'streams');
|
|
||||||
},
|
|
||||||
setResources: (state, action) => {
|
|
||||||
state.resources = action.payload;
|
|
||||||
},
|
|
||||||
setCourses: (state, action) => {
|
|
||||||
state.courses = action.payload;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { addResource, addCourse, setResources, setCourses, addWorkshop, addStream } = eventsSlice.actions;
|
|
||||||
export default eventsSlice.reducer;
|
|
@ -1,31 +0,0 @@
|
|||||||
import { createSlice } from "@reduxjs/toolkit";
|
|
||||||
|
|
||||||
export const initialRelays = [
|
|
||||||
"wss://nos.lol/",
|
|
||||||
"wss://relay.damus.io/",
|
|
||||||
"wss://relay.snort.social/",
|
|
||||||
"wss://relay.nostr.band/",
|
|
||||||
"wss://nostr.mutinywallet.com/",
|
|
||||||
"wss://relay.mutinywallet.com/",
|
|
||||||
"wss://relay.primal.net/"
|
|
||||||
];
|
|
||||||
|
|
||||||
export const userSlice = createSlice({
|
|
||||||
name: "user",
|
|
||||||
initialState: {
|
|
||||||
user: {},
|
|
||||||
relays: initialRelays,
|
|
||||||
},
|
|
||||||
reducers: {
|
|
||||||
setRelays: (state, action) => {
|
|
||||||
state.relays = [...state.relays, action.payload];
|
|
||||||
},
|
|
||||||
setUser: (state, action) => {
|
|
||||||
state.user = action.payload;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const { setRelays, setUser } = userSlice.actions;
|
|
||||||
|
|
||||||
export default userSlice.reducer;
|
|
@ -1,10 +0,0 @@
|
|||||||
import { configureStore } from "@reduxjs/toolkit";
|
|
||||||
import userReducer from "./reducers/userReducer";
|
|
||||||
import eventsReducer from "./reducers/eventsReducer";
|
|
||||||
|
|
||||||
export const store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
user: userReducer,
|
|
||||||
events: eventsReducer,
|
|
||||||
}
|
|
||||||
});
|
|
@ -10,6 +10,7 @@ module.exports = {
|
|||||||
screens: {
|
screens: {
|
||||||
'max-mob': {'max': '475px'},
|
'max-mob': {'max': '475px'},
|
||||||
'max-tab': {'max': '768px'},
|
'max-tab': {'max': '768px'},
|
||||||
|
'max-lap': {'max': '1440px'},
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user