mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-03 15:52:03 +00:00
Added example course/resource events on nostr, rendering course/resource
This commit is contained in:
parent
34748412ee
commit
9ebf92e67e
2200
package-lock.json
generated
2200
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,10 +19,13 @@
|
||||
"primereact": "^10.2.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-redux": "^9.1.0",
|
||||
"redux": "^5.0.1"
|
||||
"redux": "^5.0.1",
|
||||
"rehype-raw": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.11.21",
|
||||
"autoprefixer": "^10.0.1",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.0.4",
|
||||
|
@ -1,72 +1,58 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Carousel } from 'primereact/carousel';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import { useRouter } from 'next/router';
|
||||
import Image from 'next/image';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
import { parseEvent } from '@/utils/nostr';
|
||||
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
||||
|
||||
export default function BasicDemo() {
|
||||
const [courses, setCourses] = useState([
|
||||
{
|
||||
"title": "Lightning Wallet Frontend",
|
||||
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
|
||||
"price": 45000
|
||||
},
|
||||
{
|
||||
"title": "Lightning Wallet Backend",
|
||||
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
|
||||
"price": 70000
|
||||
},
|
||||
{
|
||||
"title": "Lightning Wallet Frontend",
|
||||
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
|
||||
"price": 45000
|
||||
},
|
||||
{
|
||||
"title": "Lightning Wallet Backend",
|
||||
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
|
||||
"price": 70000
|
||||
},
|
||||
{
|
||||
"title": "Lightning Wallet Frontend",
|
||||
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
|
||||
"price": 45000
|
||||
},
|
||||
{
|
||||
"title": "Lightning Wallet Backend",
|
||||
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
|
||||
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
|
||||
"price": 70000
|
||||
export default function CoursesCarousel() {
|
||||
const courses = useSelector((state) => state.events.courses);
|
||||
const [processedCourses, setProcessedCourses] = useState([]);
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const processCourses = courses.map(course => {
|
||||
const { id, content, title, summary, image, published_at } = parseEvent(course);
|
||||
return { id, content, title, summary, image, published_at };
|
||||
}
|
||||
]);
|
||||
);
|
||||
setProcessedCourses(processCourses);
|
||||
}, [courses]);
|
||||
|
||||
const productTemplate = (course) => {
|
||||
const courseTemplate = (course) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full px-4">
|
||||
<div onClick={() => router.push(`/course/${course.id}`)} className="flex flex-col items-center w-full px-4 cursor-pointer">
|
||||
<div className="w-86 h-60 bg-gray-200 overflow-hidden rounded-md shadow-lg">
|
||||
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover object-center" />
|
||||
<Image
|
||||
alt="resource thumbnail"
|
||||
src={returnImageProxy(course.image)}
|
||||
width={344}
|
||||
height={194}
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<h4 className="mb-1 text-center">{course.title}</h4>
|
||||
<h6 className="mt-0 mb-3 text-center">{course.price} sats</h6>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<div className='flex flex-col justify-start w-[426px]'>
|
||||
<h4 className="mb-1 font-bold text-xl">{course.title}</h4>
|
||||
<p className='truncate'>{course.summary}</p>
|
||||
<p className="text-sm mt-1 text-gray-400">Published: {formatTimestampToHowLongAgo(course.published_at)}</p>
|
||||
{/* <div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button icon="pi pi-search" rounded />
|
||||
<Button icon="pi pi-star-fill" rounded severity="success" />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold ml-[6%] my-4">Courses</h1>
|
||||
<Carousel value={courses} numVisible={3} itemTemplate={productTemplate} />
|
||||
<Carousel value={processedCourses} numVisible={3} itemTemplate={courseTemplate} />
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, {useRef} from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
@ -26,7 +26,7 @@ const UserAvatar = () => {
|
||||
|
||||
let userAvatar;
|
||||
|
||||
if (user) {
|
||||
if (user && Object.keys(user).length > 0) {
|
||||
// User exists, show username or pubkey
|
||||
const displayName = user.username || user.pubkey;
|
||||
|
||||
|
@ -1,28 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Carousel } from 'primereact/carousel';
|
||||
import { useRouter } from 'next/router';
|
||||
import Image from 'next/image';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
import { parseResourceEvent } from '@/utils/nostr';
|
||||
import { formatUnixTimestamp } from '@/utils/time';
|
||||
import { parseEvent } from '@/utils/nostr';
|
||||
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
||||
|
||||
export default function ResourcesCarousel() {
|
||||
const resources = useSelector((state) => state.events.resources);
|
||||
const [processedResources, setProcessedResources] = useState([]);
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const processResources = resources.map(resource => {
|
||||
const { content, title, summary, image, published_at } = parseResourceEvent(resource);
|
||||
return { content, title, summary, image, published_at };
|
||||
const { id, content, title, summary, image, published_at } = parseEvent(resource);
|
||||
return { id, content, title, summary, image, published_at };
|
||||
});
|
||||
setProcessedResources(processResources);
|
||||
}, [resources]);
|
||||
|
||||
const resourceTemplate = (resource) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center w-full px-4">
|
||||
<div onClick={() => router.push(`/resource/${resource.id}`)} className="flex flex-col items-center w-full px-4 cursor-pointer">
|
||||
<div className="w-86 h-60 bg-gray-200 overflow-hidden rounded-md shadow-lg">
|
||||
<Image
|
||||
alt="resource thumbnail"
|
||||
@ -32,14 +35,14 @@ export default function ResourcesCarousel() {
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<h4 className="mb-1 font-bold text-center">{resource.title}</h4>
|
||||
<p className="text-center">{resource.summary}</p>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<div className='flex flex-col justify-start w-[426px]'>
|
||||
<h4 className="mb-1 font-bold text-xl">{resource.title}</h4>
|
||||
<p className='truncate'>{resource.summary}</p>
|
||||
<p className="text-sm mt-1 text-gray-400">Published: {formatTimestampToHowLongAgo(resource.published_at)}</p>
|
||||
{/* <div className="flex flex-row items-center justify-center gap-2">
|
||||
<Button icon="pi pi-search" rounded />
|
||||
<Button icon="pi pi-star-fill" rounded severity="success" />
|
||||
</div>
|
||||
<p className="text-center mt-2">Published: {formatUnixTimestamp(resource.published_at)}</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -23,18 +23,20 @@ export const useLogin = () => {
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${publicKey}`);
|
||||
console.log('auto login response:', response);
|
||||
if (response.status === 200 && response.data) {
|
||||
dispatch(setUser(response.data));
|
||||
} else if (response.status === 204) {
|
||||
// User not found, create a new user
|
||||
const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
|
||||
console.log('kind0:', kind0);
|
||||
const fields = await findKind0Fields(kind0);
|
||||
const payload = { pubkey: publicKey, ...fields };
|
||||
|
||||
try {
|
||||
const createUserResponse = await axios.post(`/api/users`, payload);
|
||||
console.log('create user response:', createUserResponse);
|
||||
if (createUserResponse.status === 201) {
|
||||
;
|
||||
window.localStorage.setItem('pubkey', publicKey);
|
||||
dispatch(setUser(createUserResponse.data));
|
||||
} else {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SimplePool, relayInit, nip19 } from "nostr-tools";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { addResource } from "@/redux/reducers/eventsReducer";
|
||||
import { addResource, addCourse } from "@/redux/reducers/eventsReducer";
|
||||
import { initialRelays } from "@/redux/reducers/userReducer";
|
||||
|
||||
export const useNostr = () => {
|
||||
@ -67,14 +67,14 @@ export const useNostr = () => {
|
||||
|
||||
const params = {seenOnEnabled: true};
|
||||
|
||||
const hasPlebdevsTag = (eventData) => {
|
||||
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||
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 (hasPlebdevsTag(event.tags)) {
|
||||
if (hasRequiredTags(event.tags)) {
|
||||
dispatch(addResource(event));
|
||||
}
|
||||
},
|
||||
@ -87,18 +87,46 @@ export const useNostr = () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 fetchSingleEvent = async (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }]);
|
||||
|
||||
sub.on("event", (event) => {
|
||||
resolve(event);
|
||||
});
|
||||
|
||||
sub.on("error", (error) => {
|
||||
reject(error);
|
||||
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }], {
|
||||
onevent: (event) => {
|
||||
resolve(event);
|
||||
},
|
||||
onerror: (error) => {
|
||||
reject(error);
|
||||
},
|
||||
oneose: () => {
|
||||
console.log("Subscription closed");
|
||||
sub.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@ -130,6 +158,7 @@ export const useNostr = () => {
|
||||
publishEvent,
|
||||
fetchKind0,
|
||||
fetchResources,
|
||||
fetchCourses,
|
||||
getRelayStatuses,
|
||||
};
|
||||
};
|
54
src/pages/course/[slug].js
Normal file
54
src/pages/course/[slug].js
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useNostr } from "@/hooks/useNostr";
|
||||
import { parseEvent } from "@/utils/nostr";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
const MarkdownContent = ({ content }) => {
|
||||
return (
|
||||
<div>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const Course = () => {
|
||||
const [course, setCourse] = useState(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { fetchSingleEvent } = useNostr();
|
||||
|
||||
const { slug } = router.query;
|
||||
|
||||
useEffect(() => {
|
||||
const getCourse = async () => {
|
||||
if (slug) {
|
||||
const fetchedCourse = await fetchSingleEvent(slug);
|
||||
const formattedCourse = parseEvent(fetchedCourse);
|
||||
setCourse(formattedCourse);
|
||||
}
|
||||
};
|
||||
|
||||
if (slug && !course) {
|
||||
getCourse();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center mx-12">
|
||||
<h1 className="my-6 text-3xl text-center font-bold">{course?.title}</h1>
|
||||
<h2 className="text-lg text-center whitespace-pre-line">{course?.summary}</h2>
|
||||
<div className="mx-auto my-6">
|
||||
{
|
||||
course?.content && <MarkdownContent content={course?.content} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Course;
|
@ -5,12 +5,12 @@ import ResourcesCarousel from '@/components/resources/ResourcesCarousel'
|
||||
import { useNostr } from '@/hooks/useNostr'
|
||||
|
||||
export default function Home() {
|
||||
const { fetchResources } = useNostr();
|
||||
const { fetchResources, fetchCourses } = useNostr();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Fetching resources');
|
||||
fetchResources();
|
||||
}, [fetchResources]);
|
||||
fetchCourses();
|
||||
}, [fetchResources, fetchCourses]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
58
src/pages/resource/[slug].js
Normal file
58
src/pages/resource/[slug].js
Normal file
@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useNostr } from "@/hooks/useNostr";
|
||||
import { parseEvent } from "@/utils/nostr";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
const MarkdownContent = ({ content }) => {
|
||||
return (
|
||||
<div>
|
||||
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const Resource = () => {
|
||||
const [resource, setResource] = useState(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { fetchSingleEvent } = useNostr();
|
||||
|
||||
const { slug } = router.query;
|
||||
|
||||
console.log('slug:', slug);
|
||||
|
||||
useEffect(() => {
|
||||
const getResource = async () => {
|
||||
if (slug) {
|
||||
const fetchedResource = await fetchSingleEvent(slug);
|
||||
console.log('fetchedResource:', fetchedResource);
|
||||
const formattedResource = parseEvent(fetchedResource);
|
||||
console.log('formattedResource:', formattedResource.summary);
|
||||
setResource(formattedResource);
|
||||
}
|
||||
};
|
||||
|
||||
if (slug && !resource) {
|
||||
getResource();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center mx-12">
|
||||
<h1 className="my-6 text-3xl text-center font-bold">{resource?.title}</h1>
|
||||
<h2 className="text-lg text-center whitespace-pre-line">{resource?.summary}</h2>
|
||||
<div className="mx-auto my-6">
|
||||
{
|
||||
resource?.content && <MarkdownContent content={resource?.content} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Resource;
|
@ -1,13 +1,19 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
// Helper function to add single or multiple items
|
||||
// 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)) {
|
||||
// If payload is an array, spread it into the existing array
|
||||
state[key] = [...state[key], ...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], ...uniqueItems];
|
||||
} else {
|
||||
// If payload is a single item, push it into the array
|
||||
state[key].push(action.payload);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -24,7 +30,6 @@ export const eventsSlice = createSlice({
|
||||
addCourse: (state, action) => {
|
||||
addItems(state, action, 'courses');
|
||||
},
|
||||
// If you need to set the entire array at once, keep these or adjust as necessary
|
||||
setResources: (state, action) => {
|
||||
state.resources = action.payload;
|
||||
},
|
||||
@ -34,6 +39,5 @@ export const eventsSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
// Exports
|
||||
export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions;
|
||||
export default eventsSlice.reducer;
|
||||
|
@ -27,9 +27,10 @@ export const findKind0Fields = async (kind0) => {
|
||||
return fields;
|
||||
}
|
||||
|
||||
export const parseResourceEvent = (event) => {
|
||||
export const parseEvent = (event) => {
|
||||
// Initialize an object to store the extracted data
|
||||
const eventData = {
|
||||
id: event.id,
|
||||
content: event.content || '',
|
||||
title: '',
|
||||
summary: '',
|
||||
|
@ -2,3 +2,29 @@ export const formatUnixTimestamp = (time) => {
|
||||
const date = new Date(time * 1000); // Convert to milliseconds
|
||||
return date.toDateString();
|
||||
}
|
||||
|
||||
export const formatTimestampToHowLongAgo = (time) => {
|
||||
const date = new Date(time * 1000);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(months / 12);
|
||||
|
||||
if (years > 0) {
|
||||
return `${years} year${years > 1 ? 's' : ''} ago`;
|
||||
} else if (months > 0) {
|
||||
return `${months} month${months > 1 ? 's' : ''} ago`;
|
||||
} else if (days > 0) {
|
||||
return `${days} day${days > 1 ? 's' : ''} ago`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
}
|
||||
|
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"incremental": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user