├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── api │ └── upload │ │ └── ai │ │ └── route.ts ├── layout.tsx └── page.tsx ├── components.json ├── components ├── content.tsx ├── empty-state.tsx ├── get-ai.tsx ├── get-button.tsx ├── get-gifs.tsx ├── get-illustrations.tsx ├── get-pexels-videos.tsx ├── get-photos.tsx ├── get-vectors.tsx ├── header.tsx ├── icons.tsx ├── main-nav.tsx ├── media │ ├── gif.tsx │ ├── illustration.tsx │ ├── photo.tsx │ └── video.tsx ├── messages │ ├── fetch-error-message.tsx │ └── save-error-message.tsx ├── overlay.tsx ├── site-header.tsx ├── theme-provider.tsx ├── theme-toggle.tsx └── ui │ ├── alert.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── select.tsx │ └── tabs.tsx ├── config └── site.ts ├── hooks └── useDebouncedValue.tsx ├── lib ├── data.ts ├── fonts.ts ├── types.ts └── utils.ts ├── next-env.d.ts ├── next.config.mjs ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── next.svg ├── thirteen.svg └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── utils └── media-fetch.utils.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_UNSPLASH_KEY= 2 | NEXT_PUBLIC_PEXELS_KEY= 3 | NEXT_PUBLIC_PIXABAY_KEY= 4 | NEXT_PUBLIC_OPENAI_API_KEY= 5 | NEXT_PUBLIC_GIPHY_KEY= -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | .cache 3 | public 4 | node_modules 5 | *.esm.js 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off" 14 | }, 15 | "settings": { 16 | "tailwindcss": { 17 | "callees": ["cn"], 18 | "config": "tailwind.config.js" 19 | }, 20 | "next": { 21 | "rootDir": ["./"] 22 | } 23 | }, 24 | "overrides": [ 25 | { 26 | "files": ["*.ts", "*.tsx"], 27 | "parser": "@typescript-eslint/parser" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # next.js 12 | .next/ 13 | out/ 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .pnpm-debug.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | .contentlayer 36 | .env -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | cache 2 | .cache 3 | package.json 4 | package-lock.json 5 | public 6 | CHANGELOG.md 7 | .yarn 8 | dist 9 | node_modules 10 | .next 11 | build 12 | .contentlayer -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "../../node_modules/.pnpm/typescript@4.9.5/node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Cosmic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cosmic Media 2 | 3 | Search millions of high-quality, royalty-free stock photos, videos, images, and vectors from one convenient interface. Includes popular online media services: Unsplash, Pexels, Giphy, and Pixabay as well as OpenAI image generation from prompt. [Try it here](https://cosmicmedia.vercel.app/). 4 | 5 | 6 | cosmic-media 7 | 8 | 9 | cosmic-media 10 | 11 | 12 | ## How to use it 13 | You can use Cosmic Media to search and download media manually, or you can install it in your [Cosmic](https://www.cosmicjs.com/) project as an extension to save media directly in your project. 14 | 15 | 16 | ## How to install in Cosmic 17 | 18 | 1. [Log in to Cosmic](https://app.cosmicjs.com/login). 19 | 2. Go to _Project > Extensions_. 20 | 3. Find this extension and click "Install". 21 | 22 | ## Service keys 23 | 24 | The deployed app uses default API keys for Unsplash, Giphy, Pexels, Pixaby, and OpenAI. If you run into API rate-limit issues, you can update these to your own keys: 25 | 26 | 1. `unsplash_key` Register for a key [here](https://unsplash.com/developers). 27 | 2. `pexels_key` Register for a key [here](https://www.pexels.com/api). 28 | 3. `pixabay_key` Register for a key [here](https://pixabay.com/service/about/api) 29 | 4. `openai_key` Register for a key [here](https://platform.openai.com) 30 | 5. `giphy_key` Register for a key [here](https://developers.giphy.com) 31 | 32 | ### Using service keys 33 | Keys can be provided to the app in one of the following ways: 34 | 35 | 1. As query params in the URL. For example: `?unsplash_key=YOUR_UNSPLASH_KEY&pexels_key=YOUR_PEXELS_KEY` 36 | 2. Using the `.env` file. See the `.env.example` file for env var format. 37 | 3. If installed in Cosmic as an extension, go to Cosmic Media extension settings page by going to _Extensions > Cosmic Media > Settings_, find the Query Parameters section and update the following query params to your own keys: 38 | 39 | 40 | query-params 41 | 42 | 43 | ## Run locally 44 | 45 | ```bash 46 | git clone https://github.com/cosmicjs/cosmic-media-extension 47 | cd cosmic-media-extension 48 | yarn 49 | yarn dev 50 | ``` 51 | 52 | ## Built with 53 | - [shadcn/ui](https://github.com/shadcn-ui/ui) 54 | - Next.js 13 App Directory 55 | - Radix UI Primitives 56 | - Tailwind CSS 57 | - Icons from [Lucide](https://lucide.dev) 58 | - Dark mode with `next-themes` 59 | - Tailwind CSS class sorting, merging and linting. 60 | 61 | ## License 62 | 63 | Licensed under the [MIT license](https://github.com/cosmicjs/cosmic-media-extension/blob/main/LICENSE.md). 64 | -------------------------------------------------------------------------------- /app/api/upload/ai/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { createBucketClient } from "@cosmicjs/sdk" 3 | import { z } from "zod" 4 | 5 | import { COSMIC_ENV } from "@/lib/data" 6 | 7 | const RequestBodySchema = z.object({ 8 | bucket: z.object({ 9 | bucket_slug: z.string(), 10 | read_key: z.string(), 11 | write_key: z.string(), 12 | }), 13 | url: z.string(), 14 | slug: z.string(), 15 | }) 16 | 17 | export async function POST(request: Request) { 18 | const requestBody = await request.json() 19 | 20 | const parsedRequestBody = RequestBodySchema.safeParse(requestBody) 21 | if (parsedRequestBody.success === false) { 22 | return NextResponse.json( 23 | { 24 | message: "Request validation failed", 25 | error: parsedRequestBody.error, 26 | }, 27 | { status: 400 } 28 | ) 29 | } 30 | 31 | const body = parsedRequestBody.data 32 | 33 | const cosmic = createBucketClient({ 34 | bucketSlug: body.bucket.bucket_slug, 35 | readKey: body.bucket.read_key, 36 | writeKey: body.bucket.write_key, 37 | apiEnvironment: COSMIC_ENV, 38 | }) 39 | 40 | try { 41 | const response = await fetch(body.url) 42 | if (response.status !== 200) { 43 | throw new Error() 44 | } 45 | 46 | const data = await response.arrayBuffer() 47 | 48 | const buffer = Buffer.from(data) 49 | 50 | const media = { 51 | buffer, 52 | originalname: body.slug + ".jpg", 53 | } 54 | await cosmic.media.insertOne({ media }) 55 | 56 | return NextResponse.json({ message: "AI media inserted successfully" }) 57 | } catch (err) { 58 | console.error(err) 59 | return NextResponse.json( 60 | { message: "There was an error performing the request" }, 61 | { status: 500 } 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | import { Metadata } from "next" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { fontSans } from "@/lib/fonts" 6 | import { cn } from "@/lib/utils" 7 | import { ThemeProvider } from "@/components/theme-provider" 8 | 9 | export const metadata: Metadata = { 10 | title: { 11 | default: siteConfig.name, 12 | template: `%s - ${siteConfig.name}`, 13 | }, 14 | description: siteConfig.description, 15 | themeColor: [ 16 | { media: "(prefers-color-scheme: light)", color: "white" }, 17 | { media: "(prefers-color-scheme: dark)", color: "black" }, 18 | ], 19 | icons: { 20 | icon: "/favicon.ico", 21 | shortcut: "/favicon-16x16.png", 22 | apple: "/apple-touch-icon.png", 23 | }, 24 | openGraph: { 25 | images: [ 26 | "https://imgix.cosmicjs.com/eee1bf40-3799-11ee-be3f-55e1752361d4-2.png?w=1300&auto=compression", 27 | ], 28 | }, 29 | } 30 | 31 | interface RootLayoutProps { 32 | children: React.ReactNode 33 | } 34 | 35 | export default function RootLayout({ children }: RootLayoutProps) { 36 | return ( 37 | <> 38 | 39 | 40 | 46 | 47 |
48 |
{children}
49 |
50 |
51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Content from "@/components/content" 2 | import { SiteHeader } from "@/components/site-header" 3 | 4 | export default function IndexPage({ 5 | searchParams, 6 | }: { 7 | searchParams: { 8 | bucket_slug: string 9 | read_key: string 10 | write_key: string 11 | location: string 12 | } 13 | }) { 14 | return ( 15 |
16 | 17 |
18 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "tailwind": { 5 | "config": "tailwind.config.js", 6 | "css": "styles/globals.css", 7 | "baseColor": "zinc", 8 | "cssVariables": false 9 | }, 10 | "rsc": true, 11 | "aliases": { 12 | "utils": "@/lib/utils", 13 | "components": "@/components" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /components/content.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext, useMemo, useState } from "react" 4 | import isMobile from "is-mobile" 5 | import { Brush, Camera, Laugh, PenTool, Video, Wand2 } from "lucide-react" 6 | 7 | import { Bucket } from "@/lib/types" 8 | import useDebouncedValue from "@/hooks/useDebouncedValue" 9 | import { 10 | Select, 11 | SelectContent, 12 | SelectGroup, 13 | SelectItem, 14 | SelectTrigger, 15 | SelectValue, 16 | } from "@/components/ui/select" 17 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 18 | 19 | import GetAI from "./get-ai" 20 | import GetGifs from "./get-gifs" 21 | import GetIllustrations from "./get-illustrations" 22 | import GetPexelsVideos from "./get-pexels-videos" 23 | import GetPhotos from "./get-photos" 24 | import GetVectors from "./get-vectors" 25 | 26 | type Query = string 27 | type SetQuery = React.Dispatch> 28 | type DebouncedQuery = string 29 | 30 | export const GlobalContext = createContext<{ 31 | query: Query 32 | setQuery: SetQuery 33 | debouncedQuery: DebouncedQuery 34 | }>({ 35 | query: "", 36 | setQuery: () => {}, 37 | debouncedQuery: "", 38 | }) 39 | 40 | export default function Content(bucket: Bucket) { 41 | const [selectedView, setSelectedView] = useState("photos") 42 | const [query, setQuery, debouncedQuery] = useDebouncedValue("") 43 | 44 | const globalContextValue = useMemo( 45 | () => ({ 46 | query, 47 | setQuery, 48 | debouncedQuery, 49 | }), 50 | [query, setQuery, debouncedQuery] 51 | ) 52 | 53 | const showMobile = useMemo(() => isMobile(), []) 54 | 55 | function handleTabClick() { 56 | document.getElementById("search-input")?.focus() 57 | } 58 | 59 | return ( 60 | 61 |
62 | {showMobile && ( 63 |
64 | 79 | {selectedView === "photos" && ( 80 | 85 | )} 86 | {selectedView === "videos" && ( 87 | 92 | )} 93 | {selectedView === "gifs" && ( 94 | 99 | )} 100 | {selectedView === "ai" && ( 101 | 106 | )} 107 | {selectedView === "illustrations" && ( 108 | 113 | )} 114 | {selectedView === "vectors" && ( 115 | 120 | )} 121 |
122 | )} 123 | 127 | 128 | 134 | Photos 135 | 136 | 142 | 144 | 150 | Gifs 151 | 152 | 158 | AI images 159 | 160 | 166 | Illustrations 167 | 168 | 174 | 175 | Vectors 176 | 177 | 178 | 179 | 184 | 185 | 186 | 191 | 192 | 193 | 198 | 199 | 200 | 205 | 206 | 207 | 212 | 213 | 214 | 219 | 220 | 221 |
222 |
223 | ) 224 | } 225 | -------------------------------------------------------------------------------- /components/empty-state.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import { ArrowUpRight } from "lucide-react" 6 | 7 | import { Icons } from "@/components/icons" 8 | 9 | import { GlobalContext } from "./content" 10 | 11 | export default function EmptyState() { 12 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 13 | const searchParams = useSearchParams() 14 | const location = searchParams.get("location") 15 | return ( 16 |
17 |
18 | Use the search bar above to find royalty-free media from popular online 19 | media services. 20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 |
28 | {location !== "media-modal" && ( 29 |
30 |
31 | 37 |
Made by
38 |
39 | 40 |
41 |
42 |
43 |
44 | 49 | Get Cosmic Extension{" "} 50 | 51 | 52 |
53 |
54 | )} 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /components/get-ai.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext, useMemo, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import isMobile from "is-mobile" 6 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 7 | import slugify from "slugify" 8 | 9 | import { OPEN_AI_KEY } from "@/lib/data" 10 | import { Bucket, MediaModalData, Photo, PhotoData } from "@/lib/types" 11 | import { cn, emptyModalData } from "@/lib/utils" 12 | import { Button, buttonVariants } from "@/components/ui/button" 13 | import { 14 | Dialog, 15 | DialogContent, 16 | DialogDescription, 17 | DialogHeader, 18 | } from "@/components/ui/dialog" 19 | import GetButton from "@/components/get-button" 20 | import { Icons } from "@/components/icons" 21 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 22 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 23 | import Overlay from "@/components/overlay" 24 | 25 | import { GlobalContext } from "./content" 26 | import EmptyState from "./empty-state" 27 | import Header from "./header" 28 | import PhotoOutput from "./media/photo" 29 | import Input from "./ui/input" 30 | 31 | const { Configuration, OpenAIApi } = require("openai") 32 | 33 | export default function GetAI(bucket: Bucket) { 34 | const { query, setQuery } = useContext(GlobalContext) 35 | const searchParams = useSearchParams() 36 | const openai_key = searchParams.get("openai_key") || OPEN_AI_KEY 37 | const configuration = new Configuration({ 38 | apiKey: openai_key, 39 | }) 40 | const openai = new OpenAIApi(configuration) 41 | 42 | const [photos, setPhotos] = useState([]) 43 | const [generating, setGenerating] = useState(false) 44 | const [photoData, setPhotosData] = useState({ 45 | adding_media: [], 46 | added_media: [], 47 | }) 48 | const [saveError, setSaveError] = useState(false) 49 | const [serviceFetchError, setServiceFetchError] = useState() 50 | const [mediaModalData, setMediaModalData] = 51 | useState(emptyModalData) 52 | const showMobile = useMemo(() => isMobile(), []) 53 | 54 | async function handleAddAIPhotoToMedia(photo: Photo) { 55 | if (!bucket.bucket_slug) return setSaveError(true) 56 | const adding_media = [...(photoData.adding_media || []), photo.id] 57 | setPhotosData({ ...photoData, adding_media }) 58 | const slug = slugify(query) 59 | const url = photo.url 60 | try { 61 | const res = await fetch("/api/upload/ai", { 62 | method: "POST", 63 | headers: { 64 | "Content-Type": "application/json", 65 | }, 66 | body: JSON.stringify({ url, slug, bucket }), 67 | }) 68 | const adding_media = photoData.adding_media?.filter( 69 | (id: string) => id !== photo.id 70 | ) 71 | if (!res?.ok) { 72 | setPhotosData({ 73 | adding_media: [], 74 | added_media: [], 75 | }) 76 | setSaveError(true) 77 | return 78 | } 79 | const added_media = [...photoData.added_media, photo.id] 80 | setPhotosData({ ...photoData, adding_media, added_media }) 81 | } catch (e) { 82 | console.log(e) 83 | } 84 | } 85 | 86 | async function searchAIPhotos(q: string) { 87 | setServiceFetchError("") 88 | const query = q 89 | setQuery(query) 90 | if (query === "") { 91 | setPhotos([]) 92 | return 93 | } 94 | try { 95 | setGenerating(true) 96 | const response = await openai.createImage({ 97 | prompt: q, 98 | n: 8, 99 | size: "1024x1024", 100 | }) 101 | const photos = response.data.data 102 | for (const photo of photos) { 103 | photo.id = photo.url 104 | } 105 | setPhotos(photos) 106 | setGenerating(false) 107 | } catch (e: any) { 108 | setGenerating(false) 109 | setServiceFetchError("OpenAI") 110 | console.log(e) 111 | } 112 | } 113 | return ( 114 |
115 | {saveError && ( 116 | setSaveError(false)}> 117 | setSaveError(false)} 119 | onEscapeKeyDown={() => setSaveError(false)} 120 | > 121 | 122 | 123 | 124 | )} 125 | {mediaModalData.url && ( 126 | setMediaModalData(emptyModalData)}> 127 | setMediaModalData(emptyModalData)} 129 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 130 | className="md:max-w-[70vw]" 131 | > 132 | 133 | 134 |
135 | {/* eslint-disable-next-line @next/next/no-img-element */} 136 | {mediaModalData.description} 142 |
143 | 144 |
145 |
146 |
147 |
{mediaModalData.description}
148 |
149 | 159 | 164 | e.stopPropagation() 165 | } 166 | /> 167 | 168 |
169 | 172 | handleAddAIPhotoToMedia(mediaModalData.photo) 173 | } 174 | isZoom 175 | data={photoData} 176 | /> 177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 | )} 185 |
186 | setQuery(event.target.value)} 190 | onKeyUp={async (event: React.KeyboardEvent) => { 191 | const searchTerm = event.currentTarget.value 192 | try { 193 | if (event.which === 13) await searchAIPhotos(searchTerm) 194 | } catch (error) { 195 | console.error("Error occurred during search:", error) 196 | } 197 | }} 198 | /> 199 | {query && ( 200 | { 203 | setQuery("") 204 | document.getElementById("search-input")?.focus() 205 | }} 206 | className="absolute right-[115px] top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[115px] sm:top-[23px]" 207 | /> 208 | )} 209 | {/* { // TODO add loader 210 | 211 | } */} 212 | 219 |
220 | {serviceFetchError && ( 221 |
222 | 223 |
224 | )} 225 | {generating && ( 226 |
227 |
🤖  Generating images
228 |
229 | 230 |
231 |
232 | )} 233 | {!generating && photos?.length !== 0 && ( 234 |
235 | {photos?.map((photo: Photo) => ( 236 |
{ 240 | setMediaModalData({ 241 | url: photo.url, 242 | description: query, 243 | photo: photo, 244 | download_url: photo.url, 245 | name: `${photo.id}-cosmic-media.jpg`, 246 | service: "OpenAI", 247 | external_url: photo.url, 248 | }) 249 | }} 250 | > 251 | 252 | 257 | 258 | 259 | {showMobile && } 260 |
261 | ))} 262 |
263 | )} 264 | {!generating && photos?.length === 0 && } 265 |
266 | ) 267 | } 268 | -------------------------------------------------------------------------------- /components/get-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Check, Download, Loader2 } from "lucide-react" 4 | 5 | import { 6 | GiphyImage, 7 | Photo, 8 | PhotoData, 9 | PixabayPhoto, 10 | UnsplashPhoto, 11 | Video, 12 | } from "@/lib/types" 13 | import { Button } from "@/components/ui/button" 14 | 15 | export default function GetButton({ 16 | data, 17 | media, 18 | handleAddPhotoToMedia, 19 | handleAddVideoToMedia, 20 | isZoom, 21 | }: { 22 | data: PhotoData 23 | media: Photo | UnsplashPhoto | PixabayPhoto | Video | GiphyImage 24 | handleAddPhotoToMedia?: ( 25 | photo: Photo | UnsplashPhoto | PixabayPhoto | UnsplashPhoto 26 | ) => void 27 | handleAddVideoToMedia?: (video: Video) => void 28 | isZoom?: boolean 29 | }) { 30 | if (data.adding_media && data.adding_media.indexOf(media.id) !== -1) 31 | return ( 32 |
33 | 40 |
41 | ) 42 | if (data.added_media && data.added_media.indexOf(media.id) !== -1) 43 | return ( 44 |
e.stopPropagation()}> 45 | 52 |
53 | ) 54 | return ( 55 |
e.stopPropagation()} 58 | > 59 | 77 |
78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /components/get-gifs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext, useEffect, useMemo, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import isMobile from "is-mobile" 6 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 7 | 8 | import { GIPHY_KEY, GIPHY_SEARCH_URL, cosmic } from "@/lib/data" 9 | import { Bucket, GiphyImage, MediaModalData, PhotoData } from "@/lib/types" 10 | import { cn, emptyModalData } from "@/lib/utils" 11 | import { buttonVariants } from "@/components/ui/button" 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogHeader, 17 | } from "@/components/ui/dialog" 18 | import GetButton from "@/components/get-button" 19 | import { Icons } from "@/components/icons" 20 | import GifOutput from "@/components/media/gif" 21 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 22 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 23 | import Overlay from "@/components/overlay" 24 | 25 | import { GlobalContext } from "./content" 26 | import EmptyState from "./empty-state" 27 | import Header from "./header" 28 | import Input from "./ui/input" 29 | 30 | export default function GetGifs(bucket: Bucket) { 31 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 32 | const searchParams = useSearchParams() 33 | const giphy_key = searchParams.get("giphy_key") || GIPHY_KEY 34 | 35 | const [giphyImages, setGiphyImages] = useState([]) 36 | const [photoData, setPhotosData] = useState({ 37 | adding_media: [], 38 | added_media: [], 39 | }) 40 | const [saveError, setSaveError] = useState(false) 41 | const [serviceFetchError, setServiceFetchError] = useState() 42 | const [mediaModalData, setMediaModalData] = 43 | useState(emptyModalData) 44 | const showMobile = useMemo(() => isMobile(), []) 45 | 46 | const cosmicBucket = cosmic( 47 | bucket.bucket_slug, 48 | bucket.read_key, 49 | bucket.write_key 50 | ) 51 | 52 | async function searchGifs(q: string) { 53 | setServiceFetchError("") 54 | const query = q 55 | if (query === "") { 56 | setGiphyImages([]) 57 | return 58 | } 59 | try { 60 | await fetch( 61 | GIPHY_SEARCH_URL + "?api_key=" + giphy_key + "&q=" + q + "&limit=80" 62 | ) 63 | .then((res) => res.json()) 64 | .then((res) => { 65 | if (res.meta.status !== 200) setServiceFetchError("Giphy") 66 | const gifs = res.data 67 | if (!gifs) { 68 | setGiphyImages([]) 69 | } else { 70 | setGiphyImages(gifs) 71 | } 72 | }) 73 | } catch (e: any) { 74 | setGiphyImages([]) 75 | setServiceFetchError("Giphy") 76 | console.log(e) 77 | } 78 | } 79 | 80 | async function handleAddGifToMedia(image: GiphyImage) { 81 | if (!bucket.bucket_slug) return setSaveError(true) 82 | const adding_media = [...(photoData.adding_media || []), image.id] 83 | setPhotosData({ ...photoData, adding_media }) 84 | 85 | try { 86 | const response = await fetch(image?.images?.downsized_medium?.url ?? "") 87 | const blob = await response.blob() 88 | const media: any = new Blob([blob], { 89 | type: "image/gif", 90 | }) 91 | media.name = image.id + ".gif" 92 | await cosmicBucket.media.insertOne({ media }) 93 | const adding_media = photoData.adding_media?.filter( 94 | (id: string) => id !== image.id 95 | ) 96 | const added_media = [...photoData.added_media, image.id] 97 | setPhotosData({ ...photoData, adding_media, added_media }) 98 | } catch (err) { 99 | console.log(err) 100 | setSaveError(true) 101 | setPhotosData({ 102 | adding_media: [], 103 | added_media: [], 104 | }) 105 | } 106 | } 107 | useEffect(() => { 108 | searchGifs(debouncedQuery) 109 | //eslint-disable-next-line 110 | }, [debouncedQuery]) 111 | return ( 112 |
113 | {saveError && ( 114 | setSaveError(false)}> 115 | setSaveError(false)} 117 | onEscapeKeyDown={() => setSaveError(false)} 118 | > 119 | 120 | 121 | 122 | )} 123 | {mediaModalData.url && ( 124 | setMediaModalData(emptyModalData)}> 125 | setMediaModalData(emptyModalData)} 127 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 128 | className="md:max-w-[70vw]" 129 | > 130 | 131 | 132 |
133 | {/* eslint-disable-next-line @next/next/no-img-element */} 134 | {mediaModalData.description} 140 |
141 | 142 |
143 |
144 |
145 |
{mediaModalData.description}
146 |
147 | 157 | 162 | e.stopPropagation() 163 | } 164 | /> 165 | 166 |
167 | 170 | handleAddGifToMedia(mediaModalData.photo) 171 | } 172 | isZoom 173 | data={photoData} 174 | /> 175 |
176 |
177 | {mediaModalData?.creator?.name && ( 178 | 187 | )} 188 |
189 |
190 |
191 |
192 |
193 | )} 194 |
195 | setQuery(event.target.value)} 199 | /> 200 | {query && ( 201 | { 204 | setQuery("") 205 | document.getElementById("search-input")?.focus() 206 | }} 207 | className="absolute right-2 top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[12px] sm:top-[23px]" 208 | /> 209 | )} 210 | {/* { // TODO add loader 211 | 212 | } */} 213 |
214 | {serviceFetchError && ( 215 |
216 | 217 |
218 | )} 219 | {giphyImages?.length !== 0 && ( 220 |
221 | {giphyImages?.map((image: GiphyImage) => ( 222 |
{ 226 | setMediaModalData({ 227 | url: image?.images?.downsized_medium?.url, 228 | description: image?.title, 229 | photo: image, 230 | download_url: image?.images?.downsized_medium?.url, 231 | name: `${image.id}-cosmic-media.gif`, 232 | service: "giphy", 233 | external_url: image.url, 234 | creator: { 235 | name: image?.user?.display_name, 236 | url: image?.user?.profile_url, 237 | }, 238 | }) 239 | }} 240 | > 241 | 246 | handleAddGifToMedia(image)} 249 | data={photoData} 250 | /> 251 | 252 | 253 | {showMobile && } 254 |
255 | ))} 256 |
257 | )} 258 | {!query && giphyImages?.length === 0 && } 259 | {!serviceFetchError && query && giphyImages?.length === 0 && ( 260 |
261 | 262 |
263 | )} 264 |
265 | ) 266 | } 267 | -------------------------------------------------------------------------------- /components/get-illustrations.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext, useEffect, useMemo, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import isMobile from "is-mobile" 6 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 7 | 8 | import { PIXABAY_KEY, PIXABAY_SEARCH_URL, cosmic } from "@/lib/data" 9 | import { Bucket, MediaModalData, PhotoData, PixabayPhoto } from "@/lib/types" 10 | import { cn, debounce, emptyModalData } from "@/lib/utils" 11 | import { buttonVariants } from "@/components/ui/button" 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogHeader, 17 | } from "@/components/ui/dialog" 18 | import GetButton from "@/components/get-button" 19 | import { Icons } from "@/components/icons" 20 | import VectorOutput from "@/components/media/illustration" 21 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 22 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 23 | import Overlay from "@/components/overlay" 24 | 25 | import { GlobalContext } from "./content" 26 | import EmptyState from "./empty-state" 27 | import Header from "./header" 28 | import Input from "./ui/input" 29 | 30 | export default function GetIllustrations(bucket: Bucket) { 31 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 32 | const searchParams = useSearchParams() 33 | const pixabay_key = searchParams.get("pixabay_key") || PIXABAY_KEY 34 | 35 | const [pixabayIllustrations, setPixabayIllustrations] = useState< 36 | PixabayPhoto[] 37 | >([]) 38 | const [photoData, setPhotosData] = useState({ 39 | adding_media: [], 40 | added_media: [], 41 | }) 42 | const [saveError, setSaveError] = useState(false) 43 | const [serviceFetchError, setServiceFetchError] = useState() 44 | const [mediaModalData, setMediaModalData] = 45 | useState(emptyModalData) 46 | const showMobile = useMemo(() => isMobile(), []) 47 | 48 | const cosmicBucket = cosmic( 49 | bucket.bucket_slug, 50 | bucket.read_key, 51 | bucket.write_key 52 | ) 53 | 54 | async function searchPixabay(q: string) { 55 | debounce(() => setServiceFetchError("")) 56 | const query = q 57 | if (query === "") { 58 | setPixabayIllustrations([]) 59 | return 60 | } 61 | try { 62 | await fetch( 63 | PIXABAY_SEARCH_URL + 64 | "?key=" + 65 | pixabay_key + 66 | "&q=" + 67 | q + 68 | "&image_type=illustration" + 69 | "&per_page=80" 70 | ) 71 | .then((res) => res.json()) 72 | .then((data) => { 73 | const photos = data.hits 74 | if (!photos) { 75 | setPixabayIllustrations([]) 76 | } else { 77 | setPixabayIllustrations(photos) 78 | } 79 | }) 80 | } catch (e: any) { 81 | setPixabayIllustrations([]) 82 | setServiceFetchError("Pixabay") 83 | console.log(e) 84 | } 85 | } 86 | 87 | async function handleAddPixabayIllustrationToMedia(photo: PixabayPhoto) { 88 | if (!bucket.bucket_slug) return setSaveError(true) 89 | const adding_media = [...(photoData.adding_media || []), photo.id] 90 | setPhotosData({ ...photoData, adding_media }) 91 | 92 | try { 93 | const response = await fetch(photo.imageURL ?? "") 94 | const blob = await response.blob() 95 | const media: any = new Blob([blob], { 96 | type: "image/jpeg", 97 | }) 98 | media.name = photo.id + ".jpg" 99 | await cosmicBucket.media.insertOne({ media }) 100 | const adding_media = photoData.adding_media?.filter( 101 | (id: string) => id !== photo.id 102 | ) 103 | const added_media = [...photoData.added_media, photo.id] 104 | setPhotosData({ ...photoData, adding_media, added_media }) 105 | } catch (err) { 106 | console.log(err) 107 | setSaveError(true) 108 | setPhotosData({ adding_media: [], added_media: [] }) 109 | } 110 | } 111 | useEffect(() => { 112 | searchPixabay(debouncedQuery) 113 | //eslint-disable-next-line 114 | }, [debouncedQuery]) 115 | return ( 116 |
117 | {saveError && ( 118 | setSaveError(false)}> 119 | setSaveError(false)} 121 | onEscapeKeyDown={() => setSaveError(false)} 122 | > 123 | 124 | 125 | 126 | )} 127 | {mediaModalData.url && ( 128 | setMediaModalData(emptyModalData)}> 129 | setMediaModalData(emptyModalData)} 131 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 132 | className="md:max-w-[70vw]" 133 | > 134 | 135 | 136 |
137 | {/* eslint-disable-next-line @next/next/no-img-element */} 138 | {mediaModalData.description} 144 |
145 | 146 |
147 |
148 |
149 |
{mediaModalData.description}
150 |
151 | 161 | 166 | e.stopPropagation() 167 | } 168 | /> 169 | 170 |
171 | 174 | handleAddPixabayIllustrationToMedia( 175 | mediaModalData.photo 176 | ) 177 | } 178 | isZoom 179 | data={photoData} 180 | /> 181 |
182 |
183 | {mediaModalData.creator && ( 184 | 193 | )} 194 |
195 |
196 |
197 |
198 |
199 | )} 200 |
201 | setQuery(event.target.value)} 205 | /> 206 | {query && ( 207 | { 210 | setQuery("") 211 | document.getElementById("search-input")?.focus() 212 | }} 213 | className="absolute right-2 top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[12px] sm:top-[23px]" 214 | /> 215 | )} 216 | {/* { // TODO add loader 217 | 218 | } */} 219 |
220 | {serviceFetchError && ( 221 |
222 | 223 |
224 | )} 225 | {pixabayIllustrations?.length !== 0 && ( 226 |
227 | {pixabayIllustrations?.map((photo: PixabayPhoto) => ( 228 |
{ 232 | setMediaModalData({ 233 | url: photo.largeImageURL, 234 | description: photo.tags, 235 | photo, 236 | download_url: photo?.fullHDURL, 237 | name: `${photo.id}-cosmic-media.jpg`, 238 | service: "pixabay", 239 | creator: { 240 | name: photo.user, 241 | url: `https://pixabay.com/users/${photo.user_id}`, 242 | }, 243 | external_url: photo.pageURL, 244 | }) 245 | }} 246 | > 247 | 252 | 255 | handleAddPixabayIllustrationToMedia(photo) 256 | } 257 | data={photoData} 258 | /> 259 | 260 | 261 | {showMobile && } 262 |
263 | ))} 264 |
265 | )} 266 | {!query && pixabayIllustrations?.length === 0 && } 267 | {!serviceFetchError && query && pixabayIllustrations?.length === 0 && ( 268 |
269 | 270 |
271 | )} 272 |
273 | ) 274 | } 275 | -------------------------------------------------------------------------------- /components/get-pexels-videos.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext, useEffect, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 6 | import { createClient } from "pexels" 7 | 8 | import { PEXELS_KEY, cosmic } from "@/lib/data" 9 | import { Bucket, MediaModalData, Video, VideoData } from "@/lib/types" 10 | import { cn, emptyModalData } from "@/lib/utils" 11 | import { buttonVariants } from "@/components/ui/button" 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogHeader, 17 | } from "@/components/ui/dialog" 18 | import GetButton from "@/components/get-button" 19 | import { Icons } from "@/components/icons" 20 | import VideoOutput from "@/components/media/video" 21 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 22 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 23 | 24 | import { GlobalContext } from "./content" 25 | import EmptyState from "./empty-state" 26 | import Header from "./header" 27 | import Input from "./ui/input" 28 | 29 | export default function GetPexelsVideos(bucket: Bucket) { 30 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 31 | 32 | const searchParams = useSearchParams() 33 | const pexels_key = searchParams.get("pexels_key") || PEXELS_KEY 34 | const [saveError, setSaveError] = useState(false) 35 | const [serviceFetchError, setServiceFetchError] = useState() 36 | const [videos, setVideos] = useState([]) 37 | const [videoData, setVideosData] = useState({ 38 | adding_media: [], 39 | added_media: [], 40 | }) 41 | const [mediaModalData, setMediaModalData] = 42 | useState(emptyModalData) 43 | 44 | const cosmicBucket = cosmic( 45 | bucket.bucket_slug, 46 | bucket.read_key, 47 | bucket.write_key 48 | ) 49 | 50 | async function searchVideos(q: string) { 51 | setServiceFetchError("") 52 | const query = q 53 | if (query === "") { 54 | setVideos([]) 55 | return 56 | } 57 | try { 58 | const pexelsClient = createClient(pexels_key || "") 59 | await pexelsClient.videos 60 | .search({ query, per_page: 80 }) 61 | .then((res: any) => { 62 | const videos = res.videos 63 | if (!videos) { 64 | setVideos([]) 65 | } else { 66 | setVideos(videos) 67 | } 68 | }) 69 | } catch (e: any) { 70 | setVideos([]) 71 | setServiceFetchError("Pexels") 72 | console.log(e) 73 | } 74 | } 75 | 76 | async function handleAddVideoToMedia(video: Video) { 77 | if (!bucket.bucket_slug) return setSaveError(true) 78 | const adding_media = [...(videoData.adding_media || []), video.id] 79 | setVideosData({ ...videoData, adding_media }) 80 | 81 | try { 82 | const response = await fetch(video.video_files[0].link) 83 | const blob = await response.blob() 84 | const media: any = new Blob([blob], { type: "video/mp4" }) 85 | media.name = video.id + ".mp4" 86 | await cosmicBucket.media.insertOne({ media }) 87 | const adding_media = videoData.adding_media?.filter( 88 | (id: string) => id !== video.id 89 | ) 90 | const added_media = [...(videoData.added_media || []), video.id] 91 | setVideosData({ ...videoData, adding_media, added_media }) 92 | } catch (err) { 93 | console.log(err) 94 | setSaveError(true) 95 | setVideosData({ 96 | adding_media: [], 97 | added_media: [], 98 | }) 99 | } 100 | } 101 | 102 | useEffect(() => { 103 | searchVideos(debouncedQuery) 104 | //eslint-disable-next-line 105 | }, [debouncedQuery]) 106 | 107 | return ( 108 |
109 | {saveError && ( 110 | setSaveError(false)}> 111 | setSaveError(false)} 113 | onEscapeKeyDown={() => setSaveError(false)} 114 | > 115 | 116 | 117 | 118 | )} 119 | {mediaModalData.url && ( 120 | setMediaModalData(emptyModalData)}> 121 | setMediaModalData(emptyModalData)} 123 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 124 | className="md:max-w-[70vw]" 125 | > 126 | 127 | 128 |
129 | {/* eslint-disable-next-line @next/next/no-img-element */} 130 |
142 |
143 |
{mediaModalData.description}
144 |
145 | 155 | 160 | e.stopPropagation() 161 | } 162 | /> 163 | 164 |
165 | 168 | handleAddVideoToMedia(mediaModalData.video) 169 | } 170 | isZoom 171 | data={videoData} 172 | /> 173 |
174 |
175 | {mediaModalData.creator && ( 176 | 185 | )} 186 |
187 |
188 |
189 |
190 |
191 | )} 192 |
193 | setQuery(event.target.value)} 197 | /> 198 | {query && ( 199 | { 202 | setQuery("") 203 | document.getElementById("search-input")?.focus() 204 | }} 205 | className="absolute right-2 top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[12px] sm:top-[23px]" 206 | /> 207 | )} 208 | {/* { // TODO add loader 209 | 210 | } */} 211 |
212 | {serviceFetchError && ( 213 |
214 | 215 |
216 | )} 217 |
218 | {videos?.length !== 0 && ( 219 |
220 | {videos.map((video: Video) => { 221 | return ( 222 |
{ 226 | setMediaModalData({ 227 | url: video.video_pictures![0].picture, 228 | description: video.description, 229 | video: video, 230 | download_url: video.video_files![0]?.link, 231 | name: `${video.id}-cosmic-media.mp4`, 232 | service: "pexels", 233 | external_url: video.url, 234 | creator: { 235 | name: video.user.name, 236 | url: video.user.url, 237 | }, 238 | }) 239 | }} 240 | > 241 | 247 | handleAddVideoToMedia(video)} 250 | data={videoData} 251 | /> 252 | 253 | 254 |
255 | ) 256 | })} 257 |
258 | )} 259 | {!query && videos.length === 0 && } 260 | {!serviceFetchError && query && videos.length === 0 && ( 261 |
262 | 263 |
264 | )} 265 |
266 |
267 | ) 268 | } 269 | -------------------------------------------------------------------------------- /components/get-photos.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useCallback, useContext, useEffect, useMemo, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | // import { mediaFetch } from "@/utils/media-fetch.utils" 6 | import isMobile from "is-mobile" 7 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 8 | import { createClient } from "pexels" 9 | 10 | import { 11 | PEXELS_KEY, 12 | PIXABAY_KEY, 13 | PIXABAY_SEARCH_URL, 14 | UNSPLASH_KEY, 15 | UNSPLASH_SEARCH_URL, 16 | cosmic, 17 | } from "@/lib/data" 18 | import { 19 | Bucket, 20 | MediaModalData, 21 | Photo, 22 | PhotoData, 23 | PixabayPhoto, 24 | UnsplashPhoto, 25 | } from "@/lib/types" 26 | import { cn, emptyModalData } from "@/lib/utils" 27 | import { buttonVariants } from "@/components/ui/button" 28 | import { 29 | Dialog, 30 | DialogContent, 31 | DialogDescription, 32 | DialogHeader, 33 | } from "@/components/ui/dialog" 34 | import GetButton from "@/components/get-button" 35 | import { Icons } from "@/components/icons" 36 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 37 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 38 | import Overlay from "@/components/overlay" 39 | 40 | import { GlobalContext } from "./content" 41 | import EmptyState from "./empty-state" 42 | import Header from "./header" 43 | import PhotoOutput from "./media/photo" 44 | import Input from "./ui/input" 45 | 46 | export default function GetPhotos(bucket: Bucket) { 47 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 48 | 49 | const searchParams = useSearchParams() 50 | const unsplash_key = searchParams.get("unsplash_key") || UNSPLASH_KEY 51 | const pexels_key = searchParams.get("pexels_key") || PEXELS_KEY 52 | const pixabay_key = searchParams.get("pixabay_key") || PIXABAY_KEY 53 | const [saveError, setSaveError] = useState(false) 54 | const [serviceFetchError, setServiceFetchError] = useState() 55 | const [pexelsPhotos, setPexelsPhotos] = useState([]) 56 | const [pixabayPhotos, setPixabayPhotos] = useState([]) 57 | const [unsplashPhotos, setUnsplashPhotos] = useState([]) 58 | const [mediaModalData, setMediaModalData] = 59 | useState(emptyModalData) 60 | const showMobile = useMemo(() => isMobile(), []) 61 | 62 | const cosmicBucket = cosmic( 63 | bucket.bucket_slug, 64 | bucket.read_key, 65 | bucket.write_key 66 | ) 67 | 68 | const [photoData, setPhotosData] = useState({ 69 | adding_media: [], 70 | added_media: [], 71 | }) 72 | 73 | const searchUnsplashPhotos = useCallback( 74 | async (q: string) => { 75 | const query = q 76 | if (query === "") { 77 | setUnsplashPhotos([]) 78 | return 79 | } 80 | try { 81 | await fetch( 82 | UNSPLASH_SEARCH_URL + 83 | "?client_id=" + 84 | unsplash_key + 85 | "&query=" + 86 | q + 87 | "&per_page=50" 88 | ) 89 | .then((res) => res.json()) 90 | .then((data) => { 91 | if (data.errors) { 92 | setUnsplashPhotos([]) 93 | return setServiceFetchError("Unsplash") 94 | } 95 | const photos = data.results 96 | if (!photos) { 97 | setUnsplashPhotos([]) 98 | } else { 99 | setUnsplashPhotos(photos) 100 | } 101 | }) 102 | } catch (e: any) { 103 | setUnsplashPhotos([]) 104 | setServiceFetchError("Unsplash") 105 | console.log(e) 106 | } 107 | }, 108 | [unsplash_key] 109 | ) 110 | 111 | async function handleAddUnsplashPhotoToMedia(photo: UnsplashPhoto) { 112 | if (!bucket.bucket_slug) return setSaveError(true) 113 | const adding_media = [...(photoData.adding_media || []), photo.id] 114 | setPhotosData({ ...photoData, adding_media }) 115 | 116 | try { 117 | const response = await fetch(photo.urls?.full ?? "") 118 | const blob = await response.blob() 119 | const media: any = new Blob([blob], { 120 | type: "image/jpeg", 121 | }) 122 | media.name = photo.id + ".jpg" 123 | await cosmicBucket.media.insertOne({ media }) 124 | const adding_media = photoData.adding_media?.filter( 125 | (id: string) => id !== photo.id 126 | ) 127 | const added_media = [...photoData.added_media, photo.id] 128 | setPhotosData({ ...photoData, adding_media, added_media }) 129 | } catch (err) { 130 | console.log(err) 131 | setSaveError(true) 132 | setPhotosData({ adding_media: [], added_media: [] }) 133 | } 134 | } 135 | 136 | const searchPexelsPhotos = useCallback( 137 | async (q: string) => { 138 | const query = q 139 | if (query === "") { 140 | setPexelsPhotos([]) 141 | return 142 | } 143 | try { 144 | const pexelsClient = createClient(pexels_key || "") 145 | await pexelsClient.photos 146 | .search({ query, per_page: 20 }) 147 | .then((res: any) => { 148 | const photos = res.photos 149 | if (!photos) { 150 | setPexelsPhotos([]) 151 | } else { 152 | setPexelsPhotos(photos) 153 | } 154 | }) 155 | } catch (e: any) { 156 | setPexelsPhotos([]) 157 | setServiceFetchError("Pexels") 158 | console.log(e) 159 | } 160 | }, 161 | [pexels_key] 162 | ) 163 | 164 | async function handleAddPexelsPhotoToMedia(photo: Photo) { 165 | if (!bucket.bucket_slug) return setSaveError(true) 166 | const adding_media = [...(photoData.adding_media || []), photo.id] 167 | setPhotosData({ ...photoData, adding_media }) 168 | 169 | try { 170 | const response = await fetch(photo.src?.original ?? "") 171 | const blob = await response.blob() 172 | const media: any = new Blob([blob], { 173 | type: photo ? "image/jpeg" : "video/mp4", 174 | }) 175 | media.name = photo.id + ".jpg" 176 | await cosmicBucket.media.insertOne({ media }) 177 | const adding_media = photoData.adding_media?.filter( 178 | (id: string) => id !== photo.id 179 | ) 180 | const added_media = [...photoData.added_media, photo.id] 181 | setPhotosData({ ...photoData, adding_media, added_media }) 182 | } catch (err) { 183 | setSaveError(true) 184 | console.log(err) 185 | } 186 | } 187 | 188 | const searchPixabayPhotos = useCallback( 189 | async (q: string) => { 190 | const query = q 191 | if (query === "") { 192 | setPixabayPhotos([]) 193 | return 194 | } 195 | try { 196 | await fetch( 197 | PIXABAY_SEARCH_URL + 198 | "?key=" + 199 | pixabay_key + 200 | "&q=" + 201 | q + 202 | "&image_type=photo" + 203 | "&per_page=50" 204 | ) 205 | .then((res) => res.json()) 206 | .then((data) => { 207 | const photos = data.hits 208 | if (!photos) { 209 | setPixabayPhotos([]) 210 | } else { 211 | setPixabayPhotos(photos) 212 | } 213 | }) 214 | } catch (e: any) { 215 | setPixabayPhotos([]) 216 | setServiceFetchError("Pixabay") 217 | console.log(e) 218 | } 219 | }, 220 | [pixabay_key] 221 | ) 222 | 223 | async function handleAddPixabayPhotoToMedia(photo: PixabayPhoto) { 224 | if (!bucket.bucket_slug) return setSaveError(true) 225 | const adding_media = [...(photoData.adding_media || []), photo.id] 226 | setPhotosData({ ...photoData, adding_media }) 227 | 228 | try { 229 | const response = await fetch(photo.imageURL ?? "") 230 | const blob = await response.blob() 231 | const media: any = new Blob([blob], { 232 | type: "image/jpeg", 233 | }) 234 | media.name = photo.id + ".jpg" 235 | await cosmicBucket.media.insertOne({ media }) 236 | const adding_media = photoData.adding_media?.filter( 237 | (id: string) => id !== photo.id 238 | ) 239 | const added_media = [...photoData.added_media, photo.id] 240 | setPhotosData({ ...photoData, adding_media, added_media }) 241 | } catch (err) { 242 | setSaveError(true) 243 | console.log(err) 244 | } 245 | } 246 | const allPhotos = [...pexelsPhotos, ...pixabayPhotos, ...unsplashPhotos] 247 | .length 248 | 249 | const searchPhotos = useCallback( 250 | async (searchTerm: string) => { 251 | try { 252 | await Promise.allSettled([ 253 | searchUnsplashPhotos(searchTerm), 254 | searchPexelsPhotos(searchTerm), 255 | searchPixabayPhotos(searchTerm), 256 | ]) 257 | } catch (error) { 258 | console.error("Error occurred during search:", error) 259 | } 260 | }, 261 | [searchUnsplashPhotos, searchPexelsPhotos, searchPixabayPhotos] 262 | ) 263 | 264 | useEffect(() => { 265 | searchPhotos(debouncedQuery) 266 | //eslint-disable-next-line 267 | }, [debouncedQuery]) 268 | 269 | return ( 270 |
271 | {saveError && ( 272 | setSaveError(false)}> 273 | setSaveError(false)} 275 | onEscapeKeyDown={() => setSaveError(false)} 276 | > 277 | 278 | 279 | 280 | )} 281 | {mediaModalData.url && ( 282 | setMediaModalData(emptyModalData)}> 283 | setMediaModalData(emptyModalData)} 285 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 286 | className="md:max-w-[70vw]" 287 | > 288 | 289 | 290 |
291 | {/* eslint-disable-next-line @next/next/no-img-element */} 292 | {mediaModalData.description} 298 |
299 | 300 |
301 |
302 |
303 |
{mediaModalData.description}
304 |
305 | 315 | 320 | e.stopPropagation() 321 | } 322 | /> 323 | 324 |
325 | { 328 | if (mediaModalData.service === "unsplash") 329 | handleAddUnsplashPhotoToMedia(mediaModalData.photo) 330 | if (mediaModalData.service === "pexels") 331 | handleAddPexelsPhotoToMedia(mediaModalData.photo) 332 | if (mediaModalData.service === "pixabay") 333 | handleAddPixabayPhotoToMedia(mediaModalData.photo) 334 | }} 335 | isZoom 336 | data={photoData} 337 | /> 338 |
339 |
340 | {mediaModalData.creator && ( 341 | 350 | )} 351 |
352 |
353 |
354 |
355 |
356 | )} 357 |
358 | { 361 | const searchTerm = event.target.value 362 | setServiceFetchError("") 363 | setQuery(searchTerm) 364 | }} 365 | value={query} 366 | /> 367 | {query && ( 368 | { 371 | setQuery("") 372 | document.getElementById("search-input")?.focus() 373 | }} 374 | className="absolute right-2 top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[12px] sm:top-[23px]" 375 | /> 376 | )} 377 | {/* { // TODO add loader 378 | 379 | } */} 380 |
381 | {serviceFetchError && ( 382 |
383 | 384 |
385 | )} 386 | {allPhotos !== 0 && ( 387 |
388 | {unsplashPhotos?.map((photo: UnsplashPhoto) => ( 389 |
{ 393 | setMediaModalData({ 394 | url: photo?.urls?.regular, 395 | description: photo.description 396 | ? photo.description 397 | : photo.alt_description, 398 | photo, 399 | download_url: photo?.urls?.full, 400 | external_url: photo?.links?.html, 401 | name: `${photo.id}-cosmic-media.jpg`, 402 | service: "unsplash", 403 | creator: { 404 | name: `${photo.user.first_name} ${ 405 | photo.user.last_name ? photo.user.last_name : "" 406 | }`, 407 | url: photo.user.links.html, 408 | }, 409 | }) 410 | }} 411 | > 412 | 417 | 420 | handleAddUnsplashPhotoToMedia(photo) 421 | } 422 | data={photoData} 423 | /> 424 | 425 | 426 | {showMobile && } 427 |
428 | ))} 429 | {pexelsPhotos?.map((photo: Photo) => ( 430 |
{ 434 | setMediaModalData({ 435 | url: photo.src!.large2x, 436 | description: photo.alt, 437 | photo, 438 | download_url: photo?.src?.large2x, 439 | name: `${photo.id}-cosmic-media.jpg`, 440 | service: "pexels", 441 | creator: { 442 | name: `${photo.photographer}`, 443 | url: photo.photographer_url, 444 | }, 445 | external_url: photo.url, 446 | }) 447 | }} 448 | > 449 | 454 | 457 | handleAddPexelsPhotoToMedia(photo) 458 | } 459 | data={photoData} 460 | /> 461 | 462 | 463 | {showMobile && } 464 |
465 | ))} 466 | {pixabayPhotos?.map((photo: PixabayPhoto) => ( 467 |
{ 471 | setMediaModalData({ 472 | url: photo.largeImageURL, 473 | description: photo.tags, 474 | photo, 475 | download_url: photo?.fullHDURL, 476 | name: `${photo.id}-cosmic-media.jpg`, 477 | service: "pixabay", 478 | creator: { 479 | name: photo.user, 480 | url: `https://pixabay.com/users/${photo.user_id}`, 481 | }, 482 | external_url: photo.pageURL, 483 | }) 484 | }} 485 | > 486 | 491 | 494 | handleAddPixabayPhotoToMedia(photo) 495 | } 496 | data={photoData} 497 | /> 498 | 499 | 500 | {showMobile && } 501 |
502 | ))} 503 |
504 | )} 505 | {!query && allPhotos === 0 && } 506 | {query && allPhotos === 0 && ( 507 |
508 | 509 |
510 | )} 511 |
512 | ) 513 | } 514 | -------------------------------------------------------------------------------- /components/get-vectors.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useContext, useEffect, useMemo, useState } from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import isMobile from "is-mobile" 6 | import { ExternalLink, Loader2, XCircle } from "lucide-react" 7 | 8 | import { PIXABAY_KEY, PIXABAY_SEARCH_URL, cosmic } from "@/lib/data" 9 | import { Bucket, MediaModalData, PhotoData, PixabayPhoto } from "@/lib/types" 10 | import { cn, debounce, emptyModalData } from "@/lib/utils" 11 | import { buttonVariants } from "@/components/ui/button" 12 | import { 13 | Dialog, 14 | DialogContent, 15 | DialogDescription, 16 | DialogHeader, 17 | } from "@/components/ui/dialog" 18 | import GetButton from "@/components/get-button" 19 | import { Icons } from "@/components/icons" 20 | import VectorOutput from "@/components/media/illustration" 21 | import { FetchErrorMessage } from "@/components/messages/fetch-error-message" 22 | import { SaveErrorMessage } from "@/components/messages/save-error-message" 23 | import Overlay from "@/components/overlay" 24 | 25 | import { GlobalContext } from "./content" 26 | import EmptyState from "./empty-state" 27 | import Header from "./header" 28 | import Input from "./ui/input" 29 | 30 | export default function GetVectors(bucket: Bucket) { 31 | const { query, setQuery, debouncedQuery } = useContext(GlobalContext) 32 | const searchParams = useSearchParams() 33 | const pixabay_key = searchParams.get("pixabay_key") || PIXABAY_KEY 34 | 35 | const [pixabayVectors, setPixabayVectors] = useState([]) 36 | const [photoData, setPhotosData] = useState({ 37 | adding_media: [], 38 | added_media: [], 39 | }) 40 | const [saveError, setSaveError] = useState(false) 41 | const [serviceFetchError, setServiceFetchError] = useState() 42 | const [mediaModalData, setMediaModalData] = 43 | useState(emptyModalData) 44 | const showMobile = useMemo(() => isMobile(), []) 45 | 46 | const cosmicBucket = cosmic( 47 | bucket.bucket_slug, 48 | bucket.read_key, 49 | bucket.write_key 50 | ) 51 | 52 | async function searchPixabayVectors(q: string) { 53 | debounce(() => setServiceFetchError("")) 54 | const query = q 55 | if (query === "") { 56 | setPixabayVectors([]) 57 | return 58 | } 59 | try { 60 | await fetch( 61 | PIXABAY_SEARCH_URL + 62 | "?key=" + 63 | pixabay_key + 64 | "&q=" + 65 | q + 66 | "&image_type=vector" + 67 | "&per_page=80" 68 | ) 69 | .then((res) => res.json()) 70 | .then((data) => { 71 | const photos = data.hits 72 | if (!photos) { 73 | setPixabayVectors([]) 74 | } else { 75 | setPixabayVectors(photos) 76 | } 77 | }) 78 | } catch (e: any) { 79 | setPixabayVectors([]) 80 | setServiceFetchError("Pixabay") 81 | console.log(e) 82 | } 83 | } 84 | 85 | async function handleAddPixabayIllustrationToMedia(photo: PixabayPhoto) { 86 | if (!bucket.bucket_slug) return setSaveError(true) 87 | const adding_media = [...(photoData.adding_media || []), photo.id] 88 | setPhotosData({ ...photoData, adding_media }) 89 | 90 | try { 91 | const response = await fetch(photo.imageURL ?? "") 92 | const blob = await response.blob() 93 | const media: any = new Blob([blob], { 94 | type: "image/jpeg", 95 | }) 96 | media.name = photo.id + ".jpg" 97 | await cosmicBucket.media.insertOne({ media }) 98 | const adding_media = photoData.adding_media?.filter( 99 | (id: string) => id !== photo.id 100 | ) 101 | const added_media = [...photoData.added_media, photo.id] 102 | setPhotosData({ ...photoData, adding_media, added_media }) 103 | } catch (err) { 104 | console.log(err) 105 | setSaveError(true) 106 | setPhotosData({ 107 | adding_media: [], 108 | added_media: [], 109 | }) 110 | } 111 | } 112 | useEffect(() => { 113 | searchPixabayVectors(debouncedQuery) 114 | //eslint-disable-next-line 115 | }, [debouncedQuery]) 116 | return ( 117 |
118 | {saveError && ( 119 | setSaveError(false)}> 120 | setSaveError(false)} 122 | onEscapeKeyDown={() => setSaveError(false)} 123 | > 124 | 125 | 126 | 127 | )} 128 | {mediaModalData.url && ( 129 | setMediaModalData(emptyModalData)}> 130 | setMediaModalData(emptyModalData)} 132 | onEscapeKeyDown={() => setMediaModalData(emptyModalData)} 133 | className="md:max-w-[70vw]" 134 | > 135 | 136 | 137 |
138 | {/* eslint-disable-next-line @next/next/no-img-element */} 139 | {mediaModalData.description} 145 |
146 | 147 |
148 |
149 |
150 |
{mediaModalData.description}
151 |
152 | 162 | 167 | e.stopPropagation() 168 | } 169 | /> 170 | 171 |
172 | 175 | handleAddPixabayIllustrationToMedia( 176 | mediaModalData.photo 177 | ) 178 | } 179 | isZoom 180 | data={photoData} 181 | /> 182 |
183 |
184 | {mediaModalData.creator && ( 185 | 194 | )} 195 |
196 |
197 |
198 |
199 |
200 | )} 201 |
202 | setQuery(event.target.value)} 206 | /> 207 | {query && ( 208 | { 211 | setQuery("") 212 | document.getElementById("search-input")?.focus() 213 | }} 214 | className="absolute right-2 top-[37%] h-5 w-5 cursor-pointer text-gray-500 sm:right-[12px] sm:top-[23px]" 215 | /> 216 | )} 217 | {/* { // TODO add loader 218 | 219 | } */} 220 |
221 | {serviceFetchError && ( 222 |
223 | 224 |
225 | )} 226 | {pixabayVectors?.length !== 0 && ( 227 |
228 | {pixabayVectors?.map((photo: PixabayPhoto) => ( 229 |
{ 233 | setMediaModalData({ 234 | url: photo.largeImageURL, 235 | description: photo.tags, 236 | photo, 237 | download_url: photo?.fullHDURL, 238 | name: `${photo.id}-cosmic-media.jpg`, 239 | service: "pixabay", 240 | creator: { 241 | name: photo.user, 242 | url: `https://pixabay.com/users/${photo.user_id}`, 243 | }, 244 | external_url: photo.pageURL, 245 | }) 246 | }} 247 | > 248 | 253 | 256 | handleAddPixabayIllustrationToMedia(photo) 257 | } 258 | data={photoData} 259 | /> 260 | 261 | 262 | {showMobile && } 263 |
264 | ))} 265 |
266 | )} 267 | {!query && pixabayVectors?.length === 0 && } 268 | {!serviceFetchError && query && pixabayVectors?.length === 0 && ( 269 |
270 | 271 |
272 | )} 273 |
274 | ) 275 | } 276 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | function Header({ children }: React.PropsWithChildren<{}>) { 4 | return ( 5 |
6 | {children} 7 |
8 | ) 9 | } 10 | 11 | export default Header 12 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LucideProps, 3 | Moon, 4 | SunMedium, 5 | type Icon as LucideIcon, 6 | } from "lucide-react" 7 | 8 | export type Icon = LucideIcon 9 | 10 | export const Icons = { 11 | sun: SunMedium, 12 | moon: Moon, 13 | x: (props: LucideProps) => ( 14 | 15 | 16 | 17 | ), 18 | logo: (props: LucideProps) => ( 19 | 20 | 24 | 25 | ), 26 | gitHub: (props: LucideProps) => ( 27 | 28 | 32 | 33 | ), 34 | cosmic: (props: LucideProps) => ( 35 | 100 | ), 101 | unsplash: (props: LucideProps) => ( 102 | 107 | 111 | 112 | ), 113 | pexels: (props: LucideProps) => ( 114 | 115 | 116 | 123 | 128 | 133 | 134 | 135 | ), 136 | pixabay: (props: LucideProps) => ( 137 | 146 | 151 | 156 | 157 | ), 158 | giphy: (props: LucideProps) => ( 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | ), 173 | openai: (props: LucideProps) => ( 174 | 175 | 179 | 183 | 187 | 191 | 195 | 196 | 200 | 201 | ), 202 | } 203 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Link from "next/link" 3 | 4 | import { siteConfig } from "@/config/site" 5 | import { Icons } from "@/components/icons" 6 | 7 | export function MainNav() { 8 | return ( 9 |
10 | 11 | 12 | 13 | {siteConfig.navTitle} 14 | 15 | 16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/media/gif.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ExternalLink, Laugh, Loader2 } from "lucide-react" 3 | 4 | import { PhotoProps } from "@/lib/types" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | 8 | function GifOutput({ src, url, provider, children }: PhotoProps) { 9 | return ( 10 | <> 11 |
12 | 13 |
14 | {/* eslint-disable-next-line @next/next/no-img-element */} 15 | {url} 22 |
23 | 24 |
25 | 41 |
{children}
42 | 43 | ) 44 | } 45 | 46 | export default GifOutput 47 | -------------------------------------------------------------------------------- /components/media/illustration.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ExternalLink, Loader2, PenTool } from "lucide-react" 3 | 4 | import { PhotoProps } from "@/lib/types" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | 8 | function VectorOutput({ src, url, provider, children }: PhotoProps) { 9 | return ( 10 | <> 11 |
12 | 13 |
14 | {/* eslint-disable-next-line @next/next/no-img-element */} 15 | {url} 22 |
23 | 24 |
25 | 41 |
{children}
42 | 43 | ) 44 | } 45 | 46 | export default VectorOutput 47 | -------------------------------------------------------------------------------- /components/media/photo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ExternalLink, Image as ImageIcon, Loader2 } from "lucide-react" 3 | 4 | import { PhotoProps } from "@/lib/types" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | 8 | function PhotoOutput({ src, url, provider, children }: PhotoProps) { 9 | return ( 10 | <> 11 |
12 | 13 |
14 | {/* eslint-disable-next-line @next/next/no-img-element */} 15 | {url} 22 |
23 | 24 |
25 | 42 |
{children}
43 | 44 | ) 45 | } 46 | 47 | export default PhotoOutput 48 | -------------------------------------------------------------------------------- /components/media/video.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import React, { useState } from "react" 4 | import { ExternalLink, Loader2, Video } from "lucide-react" 5 | 6 | import { VideoProps } from "@/lib/types" 7 | import { cn } from "@/lib/utils" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | function VideoOutput({ src, videoSrc, url, children }: VideoProps) { 11 | const [isPlaying, setIsPlaying] = useState(false) 12 | 13 | return ( 14 |
{ 17 | setIsPlaying(false) 18 | }} 19 | onMouseEnter={() => setIsPlaying(true)} 20 | > 21 |
22 |
24 | {/* eslint-disable-next-line @next/next/no-img-element */} 25 | {isPlaying ? ( 26 | <> 27 |
66 | ) 67 | } 68 | 69 | export default VideoOutput 70 | -------------------------------------------------------------------------------- /components/messages/fetch-error-message.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AlertCircle } from "lucide-react" 4 | 5 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 6 | import { Button } from "@/components/ui/button" 7 | 8 | export function FetchErrorMessage({ 9 | service, 10 | }: { 11 | service: string 12 | }): JSX.Element { 13 | return ( 14 | 15 | 16 | 17 | Failed to fetch from {service} 18 | 19 | 20 |
21 | Fetching media from {service} failed. This may be due to a rate limit 22 | issue with the default API key. Go to the Cosmic Media read me to 23 | learn how to add your own service keys. 24 |
25 | 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/messages/save-error-message.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { 5 | DialogDescription, 6 | DialogHeader, 7 | DialogTitle, 8 | } from "@/components/ui/dialog" 9 | 10 | export function SaveErrorMessage(): JSX.Element { 11 | return ( 12 | 13 | Log in to Cosmic 14 | 15 |
16 | You will need to open this extension from your Cosmic dashboard to 17 | save media. Log in and go to Project / Extensions. 18 |
19 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /components/overlay.tsx: -------------------------------------------------------------------------------- 1 | export default function Overlay() { 2 | return ( 3 |
4 | ) 5 | } 6 | -------------------------------------------------------------------------------- /components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { ThemeToggle } from "@/components/theme-toggle" 8 | 9 | export function SiteHeader({ location }: { location?: string }) { 10 | return ( 11 |
16 |
17 | 18 |
19 | 48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { type ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import { Moon, Sun } from "lucide-react" 6 | import { useTheme } from "next-themes" 7 | 8 | import { Button } from "@/components/ui/button" 9 | 10 | export function ThemeToggle() { 11 | const searchParams = useSearchParams() 12 | const dashboardTheme = searchParams.get("theme") 13 | const { setTheme, theme } = useTheme() 14 | if (dashboardTheme) { 15 | setTheme(dashboardTheme) 16 | return <> 17 | } 18 | return ( 19 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border border-zinc-200 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-zinc-950 dark:border-zinc-800 dark:[&>svg]:text-zinc-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-white text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50", 12 | destructive: 13 | "border-red-500/50 text-red-500 dark:border-red-500 [&>svg]:text-red-500 dark:border-red-900/50 dark:text-red-900 dark:dark:border-red-900 dark:[&>svg]:text-red-900", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-cosmic-blue text-white hover:bg-cosmic-blue/90", 13 | destructive: 14 | "bg-red-500 text-red-50 hover:bg-red-500/90 dark:bg-dark-red-500 dark:hover:bg-dark-red-500/90", 15 | outline: 16 | "border border-input hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-gray-50 dark:bg-dark-gray-100 text-gray-600 dark:text-dark-gray-600 hover:bg-gray-100 dark:hover:bg-dark-gray-200", 19 | ghost: 20 | "hover:bg-gray-50 dark:hover:bg-dark-gray-50 hover:text-gray-800 dark:hover:text-dark-gray-800", 21 | link: "underline-offset-4 hover:underline text-cosmic-blue-link dark:cosmic-blue", 22 | }, 23 | size: { 24 | default: "h-10 py-2 px-4", 25 | sm: "h-9 px-3 rounded-md", 26 | lg: "h-11 px-8 rounded-md", 27 | icon: "h-10 w-10", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = ({ 14 | className, 15 | ...props 16 | }: DialogPrimitive.DialogPortalProps) => ( 17 | 18 | ) 19 | DialogPortal.displayName = DialogPrimitive.Portal.displayName 20 | 21 | const DialogOverlay = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 33 | )) 34 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 35 | 36 | const DialogContent = React.forwardRef< 37 | React.ElementRef, 38 | React.ComponentPropsWithoutRef 39 | >(({ className, children, ...props }, ref) => ( 40 | 41 | 42 | 50 | {children} 51 | 52 | 53 | Close 54 | 55 | 56 | 57 | )) 58 | DialogContent.displayName = DialogPrimitive.Content.displayName 59 | 60 | const DialogHeader = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | DialogHeader.displayName = "DialogHeader" 73 | 74 | const DialogFooter = ({ 75 | className, 76 | ...props 77 | }: React.HTMLAttributes) => ( 78 |
85 | ) 86 | DialogFooter.displayName = "DialogFooter" 87 | 88 | const DialogTitle = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 100 | )) 101 | DialogTitle.displayName = DialogPrimitive.Title.displayName 102 | 103 | const DialogDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | DialogDescription.displayName = DialogPrimitive.Description.displayName 114 | 115 | export { 116 | Dialog, 117 | DialogTrigger, 118 | DialogContent, 119 | DialogHeader, 120 | DialogFooter, 121 | DialogTitle, 122 | DialogDescription, 123 | } 124 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import { InputProps } from "@/lib/types" 4 | 5 | function Input({ placeholder, onChange, onKeyUp, value }: InputProps) { 6 | return ( 7 | 16 | ) 17 | } 18 | 19 | export default Input 20 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectContent = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, children, position = "popper", ...props }, ref) => ( 39 | 40 | 51 | 58 | {children} 59 | 60 | 61 | 62 | )) 63 | SelectContent.displayName = SelectPrimitive.Content.displayName 64 | 65 | const SelectLabel = React.forwardRef< 66 | React.ElementRef, 67 | React.ComponentPropsWithoutRef 68 | >(({ className, ...props }, ref) => ( 69 | 74 | )) 75 | SelectLabel.displayName = SelectPrimitive.Label.displayName 76 | 77 | const SelectItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef 80 | >(({ className, children, ...props }, ref) => ( 81 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {children} 96 | 97 | )) 98 | SelectItem.displayName = SelectPrimitive.Item.displayName 99 | 100 | const SelectSeparator = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 111 | 112 | export { 113 | Select, 114 | SelectGroup, 115 | SelectValue, 116 | SelectTrigger, 117 | SelectContent, 118 | SelectLabel, 119 | SelectItem, 120 | SelectSeparator, 121 | } 122 | -------------------------------------------------------------------------------- /components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /config/site.ts: -------------------------------------------------------------------------------- 1 | export type SiteConfig = typeof siteConfig 2 | 3 | export const siteConfig = { 4 | name: "Cosmic Media", 5 | navTitle: "Media", 6 | description: 7 | "Search millions of high-quality, royalty-free stock photos, videos, images, and vectors from one convenient interface.", 8 | mainNav: [ 9 | { 10 | title: "Home", 11 | href: "/", 12 | }, 13 | ], 14 | links: { 15 | x: "https://x.com/cosmicjs", 16 | github: "https://github.com/cosmicjs/cosmic-media-extension", 17 | docs: "https://www.cosmicjs.com/docs", 18 | login: "https://app.cosmicjs.com/login", 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /hooks/useDebouncedValue.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react" 2 | 3 | const useDebouncedValue = ( 4 | initialValue: string, 5 | duration = 500 6 | ): [string, Dispatch>, string] => { 7 | const [value, setValue] = useState(initialValue) 8 | const [debouncedValue, setDebouncedValue] = useState(initialValue) 9 | const timeout = useRef(null) 10 | 11 | useEffect(() => { 12 | timeout.current = setTimeout(() => setDebouncedValue(value), duration) 13 | return () => { 14 | if (timeout.current) clearTimeout(timeout.current) 15 | timeout.current = null 16 | } 17 | // eslint-disable-next-line react-hooks/exhaustive-deps 18 | }, [value]) 19 | 20 | return [value, setValue, debouncedValue] 21 | } 22 | 23 | export default useDebouncedValue 24 | -------------------------------------------------------------------------------- /lib/data.ts: -------------------------------------------------------------------------------- 1 | import { createBucketClient } from "@cosmicjs/sdk" 2 | 3 | export type TCosmicEnv = "staging" | "production" 4 | 5 | export const UNSPLASH_SEARCH_URL = "https://api.unsplash.com/search/photos" 6 | export const UNSPLASH_KEY = 7 | process.env.NEXT_PUBLIC_UNSPLASH_KEY 8 | 9 | export const PEXELS_KEY = process.env.NEXT_PUBLIC_PEXELS_KEY 10 | 11 | export const PIXABAY_SEARCH_URL = "https://pixabay.com/api/" 12 | export const PIXABAY_KEY = process.env.NEXT_PUBLIC_PIXABAY_KEY 13 | 14 | export const GIPHY_SEARCH_URL = "https://api.giphy.com/v1/gifs/search" 15 | export const GIPHY_KEY = process.env.NEXT_PUBLIC_GIPHY_KEY 16 | 17 | export const OPEN_AI_KEY = process.env.NEXT_PUBLIC_OPENAI_API_KEY 18 | 19 | export const COSMIC_ENV = (process.env.NEXT_PUBLIC_COSMIC_ENV || 'production') as TCosmicEnv 20 | 21 | export const cosmic = (bucketSlug: string, readKey: string, writeKey: string) => 22 | createBucketClient({ 23 | bucketSlug, 24 | readKey, 25 | writeKey, 26 | apiEnvironment: COSMIC_ENV 27 | }) 28 | -------------------------------------------------------------------------------- /lib/fonts.ts: -------------------------------------------------------------------------------- 1 | import { JetBrains_Mono as FontMono, Inter as FontSans } from "next/font/google" 2 | 3 | export const fontSans = FontSans({ 4 | subsets: ["latin"], 5 | variable: "--font-sans", 6 | }) 7 | 8 | export const fontMono = FontMono({ 9 | subsets: ["latin"], 10 | variable: "--font-mono", 11 | }) 12 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { ChangeEventHandler } from "react" 2 | 3 | export interface Bucket { 4 | bucket_slug: string 5 | read_key: string 6 | write_key: string 7 | } 8 | 9 | export type PhotoProps = { 10 | src: string 11 | url: string 12 | provider: string 13 | children: React.ReactNode 14 | } 15 | 16 | export type VideoProps = { 17 | src: string 18 | videoSrc: string 19 | url: string 20 | provider: string 21 | children: React.ReactNode 22 | } 23 | 24 | export type InputProps = { 25 | value: string 26 | placeholder: string 27 | onKeyUp?: (event: React.KeyboardEvent) => void 28 | onChange?: ChangeEventHandler 29 | } 30 | 31 | export type Photo = { 32 | [key: string]: any 33 | id: string 34 | name: string 35 | src?: { large2x: string; large: string; original: string } 36 | url: string 37 | } 38 | 39 | export type UnsplashPhoto = { 40 | [key: string]: any 41 | id: string 42 | name: string 43 | urls: { full: string; regular: string; small: string } 44 | url: string 45 | } 46 | 47 | export type PixabayPhoto = { 48 | [key: string]: any 49 | id: string 50 | fullHDURL: string 51 | imageURL: string 52 | largeImageURL: string 53 | pageURL: string 54 | name: string 55 | url: string 56 | } 57 | 58 | export type GiphyImage = { 59 | user: { 60 | display_name: string 61 | profile_url: string 62 | } 63 | title: string | undefined 64 | id: string 65 | images: { 66 | preview_webp: { 67 | url: string 68 | } 69 | downsized_medium: { 70 | url: string 71 | } 72 | } 73 | url: string 74 | slug: string 75 | name: string 76 | } 77 | 78 | export type Video = { 79 | [key: string]: any 80 | id: string 81 | name: string 82 | image?: string 83 | url: string 84 | } 85 | 86 | export type PhotoData = { 87 | adding_media?: string[] 88 | added_media: string[] 89 | } 90 | 91 | export type VideoData = { 92 | adding_media?: string[] 93 | added_media: string[] 94 | } 95 | 96 | export type MediaModalData = { 97 | url: string 98 | description?: string 99 | download_url?: string 100 | name: string 101 | service?: string 102 | photo?: any // TODO fix this 103 | video?: any // TODO fix this 104 | creator?: { 105 | name: string 106 | url: string 107 | } 108 | external_url?: string 109 | } 110 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function debounce(fn: Function, ms = 500) { 9 | let timeoutId: ReturnType 10 | return function (this: any, ...args: any[]) { 11 | clearTimeout(timeoutId) 12 | timeoutId = setTimeout(() => fn.apply(this, args), ms) 13 | } 14 | } 15 | 16 | export async function downloadImage(imageSrc: string, name: string) { 17 | const image = await fetch(imageSrc) 18 | const imageBlog = await image.blob() 19 | const imageURL = URL.createObjectURL(imageBlog) 20 | 21 | const link = document.createElement("a") 22 | link.href = imageURL 23 | link.download = name 24 | document.body.appendChild(link) 25 | link.click() 26 | document.body.removeChild(link) 27 | } 28 | 29 | export const emptyModalData = { 30 | url: "", 31 | description: "", 32 | download_url: "", 33 | name: "", 34 | service: "", 35 | photo: "", 36 | } 37 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | experimental: { 5 | appDir: true, 6 | }, 7 | images: { 8 | remotePatterns: [ 9 | { 10 | protocol: "https", 11 | hostname: "imgix.cosmicjs.com", 12 | }, 13 | { 14 | protocol: "https", 15 | hostname: "cdn.cosmicjs.com", 16 | }, 17 | { 18 | protocol: "https", 19 | hostname: "images.pexels.com", 20 | }, 21 | { 22 | protocol: "https", 23 | hostname: "images.unsplash.com", 24 | }, 25 | { 26 | protocol: "https", 27 | hostname: "pixabay.com", 28 | }, 29 | { 30 | protocol: "https", 31 | hostname: "cdn.pixabay.com", 32 | }, 33 | ], 34 | }, 35 | } 36 | 37 | export default nextConfig 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-template", 3 | "version": "0.0.2", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint:fix": "next lint --fix", 11 | "preview": "next build && next start", 12 | "typecheck": "tsc --noEmit", 13 | "format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache", 14 | "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache" 15 | }, 16 | "dependencies": { 17 | "@cosmicjs/sdk": "^1.0.9", 18 | "@radix-ui/react-dialog": "^1.0.4", 19 | "@radix-ui/react-select": "^1.2.2", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-tabs": "^1.0.4", 22 | "axios": "^1.4.0", 23 | "class-variance-authority": "^0.4.0", 24 | "clsx": "^1.2.1", 25 | "encoding": "^0.1.13", 26 | "is-mobile": "^4.0.0", 27 | "lucide-react": "0.105.0-alpha.4", 28 | "next": "^13.4.8", 29 | "next-themes": "^0.2.1", 30 | "openai": "^3.3.0", 31 | "pexels": "^1.4.0", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "sharp": "^0.31.3", 35 | "slugify": "^1.6.6", 36 | "tailwind-merge": "^1.13.2", 37 | "tailwindcss-animate": "^1.0.6" 38 | }, 39 | "devDependencies": { 40 | "@ianvs/prettier-plugin-sort-imports": "^3.7.2", 41 | "@types/node": "^17.0.45", 42 | "@types/react": "^18.2.14", 43 | "@types/react-dom": "^18.2.6", 44 | "@typescript-eslint/parser": "^5.61.0", 45 | "autoprefixer": "^10.4.14", 46 | "eslint": "^8.44.0", 47 | "eslint-config-next": "13.0.0", 48 | "eslint-config-prettier": "^8.8.0", 49 | "eslint-plugin-react": "^7.32.2", 50 | "eslint-plugin-tailwindcss": "^3.13.0", 51 | "postcss": "^8.4.24", 52 | "prettier": "^2.8.8", 53 | "tailwindcss": "^3.3.2", 54 | "typescript": "^4.9.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: false, 5 | singleQuote: false, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(next/(.*)$)|^(next$)", 11 | "", 12 | "", 13 | "^types$", 14 | "^@/types/(.*)$", 15 | "^@/config/(.*)$", 16 | "^@/lib/(.*)$", 17 | "^@/hooks/(.*)$", 18 | "^@/components/ui/(.*)$", 19 | "^@/components/(.*)$", 20 | "^@/styles/(.*)$", 21 | "^@/app/(.*)$", 22 | "", 23 | "^[./]", 24 | ], 25 | importOrderSeparation: false, 26 | importOrderSortSpecifiers: true, 27 | importOrderBuiltinModulesToTop: true, 28 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 29 | importOrderMergeDuplicateImports: true, 30 | importOrderCombineTypeAndValueImports: true, 31 | plugins: ["@ianvs/prettier-plugin-sort-imports"], 32 | } 33 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-media-extension/149280ec17d8b71deda3dd5b78972e1a586ff8ff/public/favicon.ico -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | * { 7 | @apply border-gray-200 dark:border-dark-gray-200; 8 | } 9 | body { 10 | @apply bg-light-background text-gray-900 dark:bg-dark-background dark:text-dark-gray-900; 11 | font-feature-settings: "rlig" 1, "calt" 1; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme") 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: ["app/**/*.{ts,tsx}", "components/**/*.{ts,tsx}"], 7 | theme: { 8 | extend: { 9 | colors: { 10 | "cosmic-blue": "#29ABE2", 11 | "cosmic-blue-10": "#ECF9FE", 12 | "cosmic-bright-blue": "#3DBFF5", 13 | "cosmic-dark-blue": "#004880", 14 | "cosmic-deep-blue": "#01669E", 15 | "cosmic-blue-lighter": "#3DBFF5", 16 | "cosmic-blue-link": "#007CBF", 17 | "cosmic-deep-purple": "#18161D", 18 | "cosmic-deeper-blue": "#06364A", 19 | "cosmic-deepest-blue": "#032636", 20 | "bright-teal": "#40C5DB", 21 | "cosmic-gray": "#D4DCF1", 22 | "cosmic-black": "#2C405A", 23 | "cosmic-nepal": "#8DABC4", 24 | "cosmic-fiord": "#404B6A", 25 | "cosmic-gray-100": "#5E6988", 26 | "cosmic-fade-blue": "#009393", 27 | "cosmic-teal-blue": "#6CEEF6", 28 | "cosmic-dark-teal": "#02131A", 29 | "cosmic-off-white": "#F8F7F9", 30 | "cosmic-blue-aa-text": "#007CBF", 31 | "cosmic-blue-aaa-text": "#006F9D", 32 | transparent: "transparent", 33 | current: "currentColor", 34 | gray: { 35 | 50: "#F7FBFC", 36 | 100: "#EBF2F5", 37 | 200: "#DDEAF0", 38 | 300: "#ABBBC2", 39 | 400: "#879499", 40 | 500: "#697980", 41 | 600: "#4B5E66", 42 | 700: "#29373D", 43 | 800: "#182125", 44 | 900: "#11171A", 45 | }, 46 | blue: { 47 | 10: "#ECF9FE", 48 | 20: "#D8F2FD", 49 | 50: "#F1F8FF", 50 | 100: "#DBEDFF", 51 | 200: "#C8E1FF", 52 | 300: "#79B8FF", 53 | 400: "#2188FF", 54 | 500: "#0366D6", 55 | 600: "#005CC5", 56 | 700: "#044289", 57 | 800: "#032F62", 58 | 900: "#05264C", 59 | }, 60 | purple: { 61 | 50: "#F5F0FF", 62 | 100: "#E6DCFD", 63 | 200: "#D1BCF9", 64 | 300: "#B392F0", 65 | 400: "#8A63D2", 66 | 500: "#6F42C1", 67 | 600: "#5A32A3", 68 | 700: "#4C2888", 69 | 800: "#3A1D6E", 70 | 900: "#29134E", 71 | }, 72 | pink: { 73 | 50: "#FFEEF8", 74 | 100: "#FEDBF0", 75 | 200: "#F9B3DD", 76 | 300: "#F692CE", 77 | 400: "#EC6CB9", 78 | 500: "#EA4AAA", 79 | 600: "#D03592", 80 | 700: "#B93A86", 81 | 800: "#99306F", 82 | 900: "#6D224F", 83 | }, 84 | red: { 85 | 50: "#FFE4E4", 86 | 100: "#FFC6C6", 87 | 200: "#F5A8A8", 88 | 300: "#FF8080", 89 | 400: "#F26060", 90 | 500: "#E23A3A", 91 | 600: "#C82121", 92 | 700: "#9D2323", 93 | 800: "#710000", 94 | 900: "#3F0000", 95 | }, 96 | orange: { 97 | 50: "#FFF4E0", 98 | 100: "#FAEACD", 99 | 200: "#EED6AC", 100 | 300: "#E8C78B", 101 | 400: "#FFAD31", 102 | 500: "#E58A00", 103 | 600: "#CB6100", 104 | 700: "#AC5300", 105 | 800: "#7B3B01", 106 | 900: "#3D1D00", 107 | }, 108 | yellow: { 109 | 50: "#FFFBDD", 110 | 100: "#FFF5B1", 111 | 200: "#FFEA7F", 112 | 300: "#FFDF5D", 113 | 400: "#FFD33D", 114 | 500: "#F9C513", 115 | 600: "#DBAB09", 116 | 700: "#B08800", 117 | 800: "#735C0F", 118 | 900: "#403308", 119 | }, 120 | green: { 121 | 50: "#F0FFF4", 122 | 100: "#DCFFE4", 123 | 200: "#BEF5CB", 124 | 300: "#85E89D", 125 | 400: "#34D058", 126 | 500: "#28A745", 127 | 600: "#22863A", 128 | 700: "#176F2C", 129 | 800: "#165C26", 130 | 900: "#144620", 131 | }, 132 | teal: { 133 | 50: "#F3FFFF", 134 | 100: "#E9FBF5", 135 | 200: "#BEECDC", 136 | 300: "#9AE1C9", 137 | 400: "#69CAAA", 138 | 500: "#21C08B", 139 | 600: "#009987", 140 | 700: "#008272", 141 | 800: "#005349", 142 | 900: "#1C332B", 143 | }, 144 | white: "#FFFFFF", 145 | black: "#0C1013", 146 | "divide-color": "#EBF0F6", 147 | "dark-divide-color": "#1F2339", 148 | "light-background": "#FFFFFF", 149 | "dark-background": "#0C1013", 150 | "dark-gray": { 151 | 900: "#F7FBFC", 152 | 800: "#EBF2F5", 153 | 700: "#DDEAF0", 154 | 600: "#ABBBC2", 155 | 500: "#879499", 156 | 400: "#697980", 157 | 300: "#4B5E66", 158 | 200: "#29373D", 159 | 100: "#182125", 160 | 50: "#11171A", 161 | }, 162 | "dark-blue": { 163 | 900: "#F1F8FF", 164 | 800: "#DBEDFF", 165 | 700: "#C8E1FF", 166 | 600: "#79B8FF", 167 | 500: "#2188FF", 168 | 400: "#0366D6", 169 | 300: "#005CC5", 170 | 200: "#044289", 171 | 100: "#032F62", 172 | 50: "#05264C", 173 | }, 174 | "dark-purple": { 175 | 900: "#F5F0FF", 176 | 800: "#E6DCFD", 177 | 700: "#D1BCF9", 178 | 600: "#B392F0", 179 | 500: "#8A63D2", 180 | 400: "#6F42C1", 181 | 300: "#5A32A3", 182 | 200: "#4C2888", 183 | 100: "#3A1D6E", 184 | 50: "#29134E", 185 | gradient: "#8C4CFF", 186 | }, 187 | "dark-pink": { 188 | 900: "#FFEEF8", 189 | 800: "#FEDBF0", 190 | 700: "#F9B3DD", 191 | 600: "#F692CE", 192 | 500: "#EC6CB9", 193 | 400: "#EA4AAA", 194 | 300: "#D03592", 195 | 200: "#B93A86", 196 | 100: "#99306F", 197 | 50: "#6D224F", 198 | }, 199 | "dark-red": { 200 | 900: "#FFE4E4", 201 | 800: "#FFC6C6", 202 | 700: "#F5A8A8", 203 | 600: "#FF8080", 204 | 500: "#F26060", 205 | 400: "#E23A3A", 206 | 300: "#C82121", 207 | 200: "#9D2323", 208 | 100: "#710000", 209 | 50: "#3F0000", 210 | }, 211 | "dark-orange": { 212 | 900: "#FFF4E0", 213 | 800: "#FAEACD", 214 | 700: "#EED6AC", 215 | 600: "#E8C78B", 216 | 500: "#FFAD31", 217 | 400: "#E58A00", 218 | 300: "#CB6100", 219 | 200: "#AC5300", 220 | 100: "#7B3B01", 221 | 50: "#3D1D00", 222 | }, 223 | "dark-yellow": { 224 | 900: "#FFFBDD", 225 | 800: "#FFF5B1", 226 | 700: "#FFEA7F", 227 | 600: "#FFDF5D", 228 | 500: "#FFD33D", 229 | 400: "#F9C513", 230 | 300: "#DBAB09", 231 | 200: "#B08800", 232 | 100: "#735C0F", 233 | 50: "#403308", 234 | }, 235 | "dark-green": { 236 | 900: "#F0FFF4", 237 | 800: "#DCFFE4", 238 | 700: "#BEF5CB", 239 | 600: "#85E89D", 240 | 500: "#34D058", 241 | 400: "#28A745", 242 | 300: "#22863A", 243 | 200: "#176F2C", 244 | 100: "#165C26", 245 | 50: "#144620", 246 | }, 247 | "dark-teal": { 248 | 900: "#F3FFFF", 249 | 800: "#E9FBF5", 250 | 700: "#BEECDC", 251 | 600: "#9AE1C9", 252 | 500: "#69CAAA", 253 | 400: "#21C08B", 254 | 300: "#009987", 255 | 200: "#008272", 256 | 100: "#005349", 257 | 50: "#1C332B", 258 | }, 259 | }, 260 | fontFamily: { 261 | sans: ["var(--font-sans)", ...fontFamily.sans], 262 | }, 263 | keyframes: { 264 | "accordion-down": { 265 | from: { height: 0 }, 266 | to: { height: "var(--radix-accordion-content-height)" }, 267 | }, 268 | "accordion-up": { 269 | from: { height: "var(--radix-accordion-content-height)" }, 270 | to: { height: 0 }, 271 | }, 272 | }, 273 | animation: { 274 | "accordion-down": "accordion-down 0.2s ease-out", 275 | "accordion-up": "accordion-up 0.2s ease-out", 276 | }, 277 | }, 278 | }, 279 | plugins: [require("tailwindcss-animate")], 280 | } 281 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "noEmit": true, 9 | "incremental": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["./*"] 19 | }, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "strictNullChecks": true 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /utils/media-fetch.utils.ts: -------------------------------------------------------------------------------- 1 | let currentAbortController: AbortController | null = null 2 | 3 | //this is still work in progress and will be replacing native fetch. 4 | 5 | export async function mediaFetch( 6 | url: string, 7 | options?: RequestInit 8 | ): Promise { 9 | // Abort the previous request, if it exists 10 | if (currentAbortController) { 11 | currentAbortController.abort() 12 | } 13 | 14 | // Create a new abort controller and store it in the variable 15 | currentAbortController = new AbortController() 16 | 17 | // Add the signal from the controller to the fetch options 18 | const fetchOptions: RequestInit = { 19 | ...options, 20 | signal: currentAbortController.signal, 21 | } 22 | 23 | try { 24 | const response = await fetch(url, fetchOptions) 25 | currentAbortController = null // Clear the current controller since the request is complete 26 | return response 27 | } catch (error: any) { 28 | if (error.name === "AbortError") { 29 | console.log("Request was aborted:", error.message) 30 | } else { 31 | console.error("Fetch error:", error) 32 | } 33 | currentAbortController = null // Clear the current controller in case of errors 34 | throw error 35 | } 36 | } 37 | 38 | //another option is to try use axios & try it's cancellation 39 | --------------------------------------------------------------------------------