-
Submit a mod
+ {title}
-
+ {isFetching ? (
+
+ ) : (
+
+ )}
diff --git a/src/routes/index.tsx b/src/routes/index.tsx
index 803fc2c..5fcf1a3 100644
--- a/src/routes/index.tsx
+++ b/src/routes/index.tsx
@@ -17,6 +17,7 @@ export const appRoutes = {
about: '/about',
blog: '/blog',
submitMod: '/submit-mod',
+ editMod: '/edit-mod/:nevent',
write: '/write',
settingsProfile: '/settings-profile',
settingsRelays: '/settings-relays',
@@ -60,6 +61,10 @@ export const routes = [
path: appRoutes.submitMod,
element:
},
+ {
+ path: appRoutes.editMod,
+ element:
+ },
{
path: appRoutes.write,
element:
diff --git a/src/styles/cardMod.css b/src/styles/cardMod.css
index e59ff12..5244511 100644
--- a/src/styles/cardMod.css
+++ b/src/styles/cardMod.css
@@ -3,7 +3,7 @@
height: 100%;
display: flex;
flex-direction: column;
- background: rgba(255,255,255,0.05);
+ background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
overflow: hidden;
background: linear-gradient(to top right, #262626, #292929, #262626);
@@ -13,12 +13,12 @@
position: relative;
width: 100%;
padding-top: 56.25%;
- background: rgba(0,0,0,0.1);
+ background: rgba(0, 0, 0, 0.1);
}
.cMMBody {
padding: 15px;
- color: rgba(255,255,255,0.5);
+ color: rgba(255, 255, 255, 0.5);
display: flex;
flex-direction: column;
grid-gap: 15px;
@@ -28,7 +28,7 @@
.cMMFoot {
width: 100%;
padding: 10px 25px;
- border-top: solid 1px rgba(255,255,255,0.05);
+ border-top: solid 1px rgba(255, 255, 255, 0.05);
font-size: 14px;
display: flex;
flex-direction: row;
@@ -43,7 +43,8 @@
transform: scale(1);
border-radius: 12px;
padding: 2px;
- box-shadow: 0 0 8px 0 rgb(0,0,0,0.05);
+ box-shadow: 0 0 8px 0 rgb(0, 0, 0, 0.05);
+ cursor: pointer;
}
.cardModMainWrapperLink:hover {
@@ -60,7 +61,12 @@
.cardModMainWrapperLink::before {
transition: ease 0.4s;
- background: linear-gradient(to top, #8000ff 0%, #232323 50%, rgba(255,255,255,0) 100%);
+ background: linear-gradient(
+ to top,
+ #8000ff 0%,
+ #232323 50%,
+ rgba(255, 255, 255, 0) 100%
+ );
content: '';
position: absolute;
top: 0;
@@ -84,7 +90,7 @@
-webkit-line-clamp: 2;
font-size: 20px;
line-height: 1.25;
- color: rgba(255,255,255,0.75);
+ color: rgba(255, 255, 255, 0.75);
font-weight: bold;
}
@@ -93,7 +99,7 @@
-webkit-box-orient: vertical;
overflow: hidden;
-webkit-line-clamp: 3;
- color: rgba(255,255,255,0.5);
+ color: rgba(255, 255, 255, 0.5);
font-size: 15px;
line-height: 1.5;
}
@@ -113,6 +119,5 @@
grid-gap: 5px;
justify-content: center;
align-items: center;
- color: rgba(255,255,255,0.25);
+ color: rgba(255, 255, 255, 0.25);
}
-
diff --git a/src/styles/pagination.css b/src/styles/pagination.css
index 2e739b1..a59fe41 100644
--- a/src/styles/pagination.css
+++ b/src/styles/pagination.css
@@ -35,19 +35,23 @@
flex-direction: column;
justify-content: center;
align-items: center;
- background: rgba(35,35,35,0);
+ background: rgba(35, 35, 35, 0);
border-radius: 10px;
height: 100%;
- box-shadow: 0 0 8px 0 rgba(0,0,0,0);
+ box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0);
transform: scale(1);
- border: solid 1px rgba(255,255,255,0);
- color: rgba(255,255,255,0.1);
+ border: solid 1px rgba(255, 255, 255, 0);
+ color: rgba(255, 255, 255, 0.1);
font-weight: bold;
}
.PaginationMainInsideBox.PaginationMainInsideBoxArrows {
}
+.PaginationMainInsideBox.PaginationMainInsideBoxArrows:disabled {
+ cursor: not-allowed;
+}
+
@media (max-width: 768px) {
.PaginationMainInsideBox.PaginationMainInsideBoxArrows {
order: 2;
@@ -60,10 +64,10 @@
text-decoration: unset;
color: unset;
background: linear-gradient(to top right, #232323, #262626, #232323);
- box-shadow: 0 0 16px 0 rgba(0,0,0,0.1);
+ box-shadow: 0 0 16px 0 rgba(0, 0, 0, 0.1);
transform: scale(1.01);
- border: solid 1px rgba(255,255,255,0.1);
- color: rgba(255,255,255,0.85);
+ border: solid 1px rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.85);
}
.PaginationMainInsideBox:active {
@@ -76,8 +80,8 @@
text-decoration: unset;
color: unset;
transform: scale(1.01);
- border: solid 1px rgba(255,255,255,0.1);
- color: rgba(255,255,255,0.75);
+ border: solid 1px rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.75);
}
.PMIBDots {
@@ -100,4 +104,3 @@
justify-content: space-around;
}
}
-
diff --git a/src/types/mod.ts b/src/types/mod.ts
index 0997795..06650af 100644
--- a/src/types/mod.ts
+++ b/src/types/mod.ts
@@ -1,4 +1,7 @@
-export interface FormState {
+export interface ModFormState {
+ dTag: string
+ aTag: string
+ rTag: string
game: string
title: string
body: string
@@ -19,10 +22,15 @@ export interface DownloadUrl {
customNote: string
}
-export interface ModDetails extends Omit
{
+export interface ModDetails extends Omit {
+ id: string
published_at: number
edited_at: number
- site: string
author: string
tags: string[]
}
+
+export interface MuteLists {
+ authors: string[]
+ eventIds: string[]
+}
diff --git a/src/utils/mod.ts b/src/utils/mod.ts
index 6cb04ee..b85376f 100644
--- a/src/utils/mod.ts
+++ b/src/utils/mod.ts
@@ -1,6 +1,10 @@
-import { Event } from 'nostr-tools'
+import { Event, Filter, kinds } from 'nostr-tools'
import { getTagValue } from './nostr'
-import { ModDetails } from '../types'
+import { ModFormState, ModDetails } from '../types'
+import { RelayController } from '../controllers'
+import { log, LogType } from './utils'
+import { toast } from 'react-toastify'
+import { T_TAG_VALUE } from '../constants'
/**
* Extracts and normalizes mod data from an event.
@@ -25,16 +29,19 @@ export const extractModData = (event: Event): ModDetails => {
}
return {
+ id: event.id,
+ dTag: getFirstTagValue('d'),
+ aTag: getFirstTagValue('a'),
+ rTag: getFirstTagValue('r'),
author: event.pubkey,
edited_at: event.created_at,
body: event.content,
- site: getFirstTagValue('t'),
published_at: getIntTagValue('published_at'),
game: getFirstTagValue('game'),
title: getFirstTagValue('title'),
featuredImageUrl: getFirstTagValue('featuredImageUrl'),
summary: getFirstTagValue('summary'),
- nsfw: Boolean(getFirstTagValue('nsfw')),
+ nsfw: getFirstTagValue('nsfw') === 'true',
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
tags: getTagValue(event, 'tags') || [],
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
@@ -79,6 +86,8 @@ export const isModDataComplete = (event: Event): boolean => {
// Check if all required fields are present
return (
+ hasTagValue('d') &&
+ hasTagValue('a') &&
hasTagValue('t') &&
hasTagValue('published_at') &&
hasTagValue('game') &&
@@ -91,3 +100,87 @@ export const isModDataComplete = (event: Event): boolean => {
getTagValue(event, 'downloadUrls') !== null
)
}
+
+/**
+ * Initializes the form state with values from existing mod data or defaults.
+ *
+ * @param existingModData - An optional object containing existing mod details. If provided, its values will be used to populate the form state.
+ * @returns The initial state for the form, with values from existingModData if available, otherwise default values.
+ */
+export const initializeFormState = (
+ existingModData?: ModDetails
+): ModFormState => ({
+ dTag: existingModData?.dTag || '',
+ aTag: existingModData?.aTag || '',
+ rTag: existingModData?.rTag || window.location.host,
+ game: existingModData?.game || '',
+ title: existingModData?.title || '',
+ body: existingModData?.body || '',
+ featuredImageUrl: existingModData?.featuredImageUrl || '',
+ summary: existingModData?.summary || '',
+ nsfw: existingModData?.nsfw || false,
+ screenshotsUrls: existingModData?.screenshotsUrls || [''],
+ tags: existingModData?.tags.join(',') || '',
+ downloadUrls: existingModData?.downloadUrls || [
+ {
+ url: '',
+ hash: '',
+ signatureKey: '',
+ malwareScanLink: '',
+ modVersion: '',
+ customNote: ''
+ }
+ ]
+})
+
+/**
+ * Fetches a list of mods based on the provided source.
+ *
+ * @param source - The source URL to filter the mods. If it matches the current window location,
+ * it adds a filter condition to the request.
+ * @param until - Optional timestamp to filter events until this time.
+ * @param since - Optional timestamp to filter events from this time.
+ * @returns A promise that resolves to an array of `ModDetails` objects. In case of an error,
+ * it logs the error and shows a notification, then returns an empty array.
+ */
+export const fetchMods = async (
+ source: string,
+ until?: number,
+ since?: number
+): Promise => {
+ // Define the filter criteria for fetching mods
+ const filter: Filter = {
+ kinds: [kinds.ClassifiedListing], // Specify the kind of events to fetch
+ limit: 20, // Limit the number of events fetched to 20
+ '#t': [T_TAG_VALUE],
+ until, // Optional filter to fetch events until this timestamp
+ since // Optional filter to fetch events from this timestamp
+ }
+
+ // If the source matches the current window location, add a filter condition
+ if (source === window.location.host) {
+ filter['#r'] = [window.location.host] // Add a tag filter for the current host
+ }
+
+ // Fetch events from the relay using the defined filter
+ return RelayController.getInstance()
+ .fetchEvents(filter, []) // Pass the filter and an empty array of options
+ .then((events) => {
+ console.log('events :>> ', events)
+
+ // Convert the fetched events into a list of mods
+ const modList = constructModListFromEvents(events)
+ return modList // Return the list of mods
+ })
+ .catch((err) => {
+ // Log the error and show a notification if fetching fails
+ log(
+ true,
+ LogType.Error,
+ 'An error occurred in fetching mods from relays',
+ err
+ )
+ toast.error('An error occurred in fetching mods from relays') // Show error notification
+ return [] as ModDetails[] // Return an empty array in case of an error
+ })
+}
diff --git a/src/utils/url.ts b/src/utils/url.ts
index 8ccad5f..711432c 100644
--- a/src/utils/url.ts
+++ b/src/utils/url.ts
@@ -68,3 +68,25 @@ export const isReachable = async (url: string) => {
return false
}
}
+
+/**
+ * Extracts a filename from a given URL.
+ *
+ * @param url - The URL from which to extract the filename.
+ * @returns The filename extracted from the URL. If no filename can be extracted, a default name is provided.
+ */
+export const getFilenameFromUrl = (url: string): string => {
+ // Create a URL object to parse the provided URL string
+ const urlObj = new URL(url)
+
+ // Extract the pathname from the URL object
+ const pathname = urlObj.pathname
+
+ // Extract the filename from the pathname. The filename is the last segment after the last '/'
+ // If pathname is empty or does not end with a filename, use 'downloaded_file' as the default
+ const filename =
+ pathname.substring(pathname.lastIndexOf('/') + 1) || 'downloaded_file'
+
+ // Return the extracted filename
+ return filename
+}
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index 504d101..96b45ee 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -34,3 +34,36 @@ export const timeout = (ms: number = 60000) => {
}, ms) // Timeout duration in milliseconds
})
}
+
+/**
+ * Copies the given text to the clipboard.
+ *
+ * @param text - The text to be copied to the clipboard.
+ * @returns A promise that resolves to a boolean indicating success or failure.
+ */
+export const copyTextToClipboard = async (text: string): Promise => {
+ try {
+ // Check if the Clipboard API is available
+ if (navigator.clipboard) {
+ // Use the Clipboard API to write the text to the clipboard
+ await navigator.clipboard.writeText(text)
+ return true // Successfully copied
+ } else {
+ // Clipboard API is not available, fall back to a manual method
+ const textarea = document.createElement('textarea')
+ textarea.value = text
+ // Ensure the textarea is not visible to the user
+ textarea.style.position = 'absolute'
+ textarea.style.left = '-9999px'
+ document.body.appendChild(textarea)
+ textarea.select()
+ // Attempt to copy the text to the clipboard
+ const successful = document.execCommand('copy')
+ document.body.removeChild(textarea)
+ return successful
+ }
+ } catch (error) {
+ console.error('Failed to copy text to clipboard', error)
+ return false // Failed to copy
+ }
+}