(chore): initial MVP release v0.1.0

This commit is contained in:
2025-05-01 21:39:12 +05:30
commit bd9483374f
36 changed files with 10146 additions and 0 deletions

90
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
on:
push:
tags:
- 'v*.*.*'
name: 🚀 Release on GitHub
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: 🚚 Checkout repository
uses: actions/checkout@v4
- name: 📦 Setup pnpm
uses: pnpm/action-setup@v4
- name: 📦 Setup node
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'pnpm'
- name: 🛠️ Install dependencies
run: pnpm install
- name: 🤐 Zip extensions
run: |
pnpm zip
pnpm zip:firefox
- name: ✨ Extract version number
id: extract_version
run: |
VERSION_NUM=$(echo "${{ github.ref_name }}" | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+)(-.*)?$/\1/')
echo "version=$VERSION_NUM" >> $GITHUB_ENV
- name: 📄 Read and process changelog
id: changelog
shell: bash
run: |
if [ -f CHANGELOG.md ]; then
# Read and replace placeholders
CONTENT=$(cat CHANGELOG.md)
CONTENT=${CONTENT//<release_tag>/${{ github.ref_name }}}
CONTENT=${CONTENT//<version>/${{ env.version }}}
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Also save to a processed file for later use
echo "$CONTENT" > CHANGELOG_PROCESSED.md
else
echo "content=No changelog found" >> $GITHUB_OUTPUT
echo "No changelog found" > CHANGELOG_PROCESSED.md
fi
- name: 📝 Create latest.json
id: create_latest_json
shell: bash
run: |
# Create latest.json
cat > latest.json << EOF
{
"version": "${{env.version}}",
"notes": $(cat CHANGELOG_PROCESSED.md | jq -s -R .),
"browsers": {
"chrome": {
"url": "https://github.com/${{github.repository}}/releases/download/${{github.ref_name}}/${{github.event.repository.name}}-${{env.version}}-chrome.zip"
},
"firefox": {
"url": "https://github.com/${{github.repository}}/releases/download/${{github.ref_name}}/${{github.event.repository.name}}-${{env.version}}-firefox.zip"
}
}
}
EOF
- name: 🚀 Upload to github releases
uses: softprops/action-gh-release@v2
with:
name: ${{github.event.repository.name}}-${{github.ref_name}}
body_path: CHANGELOG_PROCESSED.md
files: |
.output/${{github.event.repository.name}}-${{env.version}}-chrome.zip
.output/${{github.event.repository.name}}-${{env.version}}-firefox.zip
.output/${{github.event.repository.name}}-${{env.version}}-sources.zip
latest.json
draft: false
prerelease: false
make_latest: true

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.output
stats.html
stats-*.json
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

10
CHANGELOG.md Normal file
View File

@@ -0,0 +1,10 @@
### ✨ Changelog
- initial MVP release v0.1.0
### ⬇️ Download Section
| Type\Browser | Chrome / Chromium based | Firefox / Firefox based |
| :---- | :---- | :---- |
| Store Listed (Recommended - Auto updates) | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| Unpackable ZIP (Manual updates) | [Download](https://github.com/neosubhamoy/neodlp-extension/releases/download/<release_tag>/neodlp-extension-<version>-chrome.zip) | [Download](https://github.com/neosubhamoy/neodlp-extension/releases/download/<release_tag>/neodlp-extension-<version>-firefox.zip) |

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Subhamoy Biswas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# NeoDLP Extension
NeoDLP (Neo Downloader Plus) Browser Integration
[![status](https://img.shields.io/badge/status-active-brightgreen.svg?style=flat)](https://github.com/neosubhamoy/neodlp-extension)
[![github tag](https://img.shields.io/github/v/tag/neosubhamoy/neodlp-extension?color=yellow)](https://github.com/neosubhamoy/neodlp-extension)
[![PRs](https://img.shields.io/badge/PRs-welcome-blue.svg?style=flat)](https://github.com/neosubhamoy/neodlp-extension)
> **🥰 Liked this project? Please consider giving it a Star (🌟) on github to show us your appreciation and help the algorythm recommend this project to even more awesome people like you!**
### 📎 Pre-Requirements
- [NeoDLP](https://github.com/neosubhamoy/neodlp) (Installed and Running)
### ⬇️ Download and Installation
#### Manual Installation (in Google Chrome / Chromium based browsers)
1. Download the `neodlp-extension-<version>-chrome.zip` file from the [latest release](https://github.com/neosubhamoy/neodlp-extension/releases).
2. Extract the `.zip` file (using any zip extractor software. eg: 7zip, WinRAR etc.).
3. Open Google Chrome and navigate to `chrome://extensions/`.
4. Enable "Developer mode" in the top right corner.
5. Click on "Load unpacked" and select the newly extracted folder. Done! That's it...!!
#### Manual Installation (in Mozilla Firefox)
1. Download the `neodlp-extension-<version>-firefox.zip` file from the [latest release](https://github.com/neosubhamoy/neodlp-extension/releases).
2. Open Firefox and navigate to `about:addons`.
3. Click on the gear icon (top right) and select "Install Add-on From File...".
4. Select the downloaded `.zip` file. Done! That's it...!!
### ⚡ Technologies Used
![WXT](https://img.shields.io/badge/WXT-67D55E.svg?style=for-the-badge&logo=WXT&logoColor=white)
![React](https://img.shields.io/badge/React-61DAFB.svg?style=for-the-badge&logo=React&logoColor=black)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6.svg?style=for-the-badge&logo=TypeScript&logoColor=white)
![ShadCnUi](https://img.shields.io/badge/shadcn/ui-000000.svg?style=for-the-badge&logo=shadcn/ui&logoColor=white)
### 🛠️ Contributing / Building from Source
Want to be part of this? Feel free to contribute...!! Pull Requests are always welcome...!! (^_^) Follow these simple steps to start building:
* Make sure to install Node.js, Git before proceeding.
1. Fork this repo in your github account.
2. Git clone the forked repo in your local machine.
3. Install Node.js dependencies: `npm install`
4. Run development / build / zipping process
```code
npm run dev # for development (chrome)
npm run dev:firefox # for development (firefox)
npm run build # for production build (chrome)
npm run build:firefox # for production build (firefox)
npm run zip # for production zip creation (chrome)
npm run zip:firefox # for production zip creation (firefox)
```
5. Do the changes, Send a Pull Request with proper Description (NOTE: Pull Requests Without Proper Description will be Rejected)
**⭕ Noticed any Bugs or Want to give us some suggetions? Always feel free to open a GitHub Issue. We would love to hear from you...!!**
### 📝 License
NeoDLP Extension is Licensed under the [MIT license](https://github.com/neosubhamoy/neodlp-extension/blob/main/LICENSE). Anyone can view, modify, use (personal and commercial) or distribute it's sources without any attribution and extra permissions.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "entrypoints/popup/style.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

13
components/footer.tsx Normal file
View File

@@ -0,0 +1,13 @@
export default function Footer() {
const manifest = browser.runtime.getManifest();
return (
<footer className="flex flex-col items-center justify-center text-center w-full">
<p className="text-xs text-muted-foreground">
<a href={manifest.homepage_url} target="_blank" className="">NeoDLP Extension</a> &copy; {new Date().getFullYear()} - <a href="https://github.com/neosubhamoy/neodlp-extension/blob/main/LICENSE" target="_blank">MIT License</a> <br></br> Made with by <a href="https://neosubhamoy.com" target="_blank">Subhamoy</a>
</p>
</footer>
)
}

18
components/header.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Card } from "@/components/ui/card";
import { NeoDlpLogo } from "@/components/icons/neodlp";
export default function Header() {
const manifest = browser.runtime.getManifest();
return (
<Card className="p-4">
<div className="flex items-center justify-center space-x-4">
<NeoDlpLogo className="w-8 h-8 rounded-lg" />
<div className="flex flex-col items-start">
<h1 className="text-sm font-semibold">{manifest.name}</h1>
<p className="text-xs">Extension - v{manifest.version}</p>
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,15 @@
export function NeoDlpLogo({ className }: { className?: string }) {
return (
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<rect width="1024" height="1024" fill="url(#paint0_linear_10_2)"/>
<path d="M529.252 811.098C519.472 820.96 503.528 820.96 493.748 811.098L256.265 571.603C240.619 555.824 251.796 529 274.017 529H748.983C771.204 529 782.381 555.824 766.735 571.603L529.252 811.098Z" fill="#FAFAFA"/>
<rect x="355" y="222" width="313" height="346" rx="25" fill="#FAFAFA"/>
<defs>
<linearGradient id="paint0_linear_10_2" x1="129.5" y1="148.5" x2="921" y2="863" gradientUnits="userSpaceOnUse">
<stop stopColor="#4444FF"/>
<stop offset="1" stopColor="#FF43D0"/>
</linearGradient>
</defs>
</svg>
)
}

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

66
components/ui/alert.tsx Normal file
View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

59
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

165
components/ui/form.tsx Normal file
View File

@@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

29
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

112
entrypoints/background.ts Normal file
View File

@@ -0,0 +1,112 @@
import { WebsocketMessage } from "@/types/websocket";
export default defineBackground(() => {
const sendMessageToNativeHost = (message: WebsocketMessage) => {
return new Promise((resolve, reject) => {
let port = browser.runtime.connectNative('com.neosubhamoy.neodlp');
port.onMessage.addListener((response) => {
console.log('Received from native host:', response);
if (response.status === 'success') {
resolve(response.response);
} else if (response.status === 'error') {
reject(new Error(response.message));
}
});
port.onDisconnect.addListener(() => {
if (browser.runtime.lastError) {
reject(new Error(browser.runtime.lastError.message));
}
});
port.postMessage(message);
});
}
// Listen for messages from the popup or content scripts
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'download') {
const url = request.url;
sendMessageToNativeHost({url: url, command: 'download', argument: ''}).then(response => sendResponse(response))
return true; // Keep the message channel open for sendResponse to be called asynchronously
}
});
// Listen for the keyboard commands
browser.commands.onCommand.addListener(async (command) => {
if (command === "neodlp:quick-download") {
try {
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
const activeTab = tabs[0];
if (activeTab && activeTab.url) {
console.log("Quick download triggered for URL:", activeTab.url);
const response = await sendMessageToNativeHost({
url: activeTab.url,
command: 'download',
argument: ''
});
console.log("Quick download response:", response);
} else {
console.error("No active tab or URL found for quick download");
}
} catch (error) {
console.error("Error in quick download:", error);
}
}
});
// Clear existing context menus before creating new ones
browser.contextMenus.removeAll().then(() => {
// Context menu for quick download
browser.contextMenus.create({
id: "quick-download:page",
title: "Download with Neo Downloader Plus",
contexts: ["page"]
});
browser.contextMenus.create({
id: "quick-download:link",
title: "Download Link with Neo Downloader Plus",
contexts: ["link"]
});
browser.contextMenus.create({
id: "quick-download:media",
title: "Download Media with Neo Downloader Plus",
contexts: ["video", "audio"]
});
browser.contextMenus.create({
id: "quick-download:selection",
title: "Download Selection with Neo Downloader Plus",
contexts: ["selection"]
});
});
browser.contextMenus.onClicked.addListener((info, tab) => {
let url = '';
if (info.menuItemId === "quick-download:page") {
if(!info.pageUrl) return;
url = info.pageUrl;
} else if (info.menuItemId === "quick-download:link") {
if(!info.linkUrl) return;
url = info.linkUrl;
} else if (info.menuItemId === "quick-download:media") {
if(!info.srcUrl) return;
url = info.srcUrl;
} else if (info.menuItemId === "quick-download:selection") {
if(!info.selectionText) return;
url = info.selectionText;
}
if (!url) return;
sendMessageToNativeHost({url: url, command: 'download', argument: ''}).then(response => {
console.log("Context menu download response:", response);
}).catch(error => {
console.error("Error in context menu download:", error);
});
});
});

38
entrypoints/popup/App.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
import { ThemeProvider } from "@/components/theme-provider";
import { Settings } from "@/types/settings";
function App({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<Settings>({
theme: "system",
autofill_url: true,
});
// loading the settings from storage if available, overwriting the default values when the component mounts
useEffect(() => {
const loadSettings = async () => {
try {
const result = await browser.storage.local.get('settings');
if (result.settings) {
// Merge saved settings with default settings
// Only override keys that exist in saved settings, keeping defaults otherwise
setSettings(prevSettings => ({
...prevSettings,
...result.settings
}));
}
} catch (error) {
console.error("Failed to load settings:", error);
}
};
loadSettings();
}, []);
return (
<ThemeProvider defaultTheme={settings.theme || "system"} storageKey="vite-ui-theme">
{children}
</ThemeProvider>
);
}
export default App;

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Neo Downloader Plus</title>
<meta name="manifest.type" content="browser_action" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from '@/entrypoints/popup/App.tsx';
import '@/entrypoints/popup/style.css';
import { HashRouter, Routes, Route } from "react-router-dom";
import RootLayout from '@/pages/layout/root';
import HomePage from '@/pages/home';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App>
<Routes>
<Route path="/" element={<RootLayout />}>
<Route index element={<HomePage />} />
</Route>
</Routes>
</App>
</HashRouter>
</React.StrictMode>,
);

123
entrypoints/popup/style.css Normal file
View File

@@ -0,0 +1,123 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

8598
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "neodlp-extension",
"description": "NeoDLP Browser Integration",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
"compile": "tsc --noEmit",
"postinstall": "wxt prepare"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.8",
"@tailwindcss/vite": "^4.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.501.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.0",
"react-router-dom": "^7.5.1",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4",
"tw-animate-css": "^1.2.7",
"zod": "^3.24.3"
},
"devDependencies": {
"@types/node": "^22.14.1",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
"@wxt-dev/module-react": "^1.1.3",
"typescript": "^5.8.3",
"wxt": "^0.20.0"
}
}

271
pages/home.tsx Normal file
View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { AlertCircle, Download, Loader2, LucideIcon, Monitor, Moon, Sun } from "lucide-react";
import { type Browser } from 'wxt/browser';
import { Textarea } from "@/components/ui/textarea";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Settings } from "@/types/settings";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { useTheme } from "@/components/theme-provider";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const downloadFormSchema = z.object({
url: z.string().min(1, { message: "URL is required" }).url({message: "Invalid URL format." }),
});
export default function HomePage() {
const { setTheme } = useTheme();
const [isDownloading, setIsDownloading] = useState(false);
const [isLoadingSettings, setIsLoadingSettings] = useState(true);
const [isUpdatingSettings, setIsUpdatingSettings] = useState(false);
const [showNotRunningAlert, setShowNotRunningAlert] = useState(false);
const [settings, setSettings] = useState<Settings>({
theme: "system",
autofill_url: true,
});
const themeOptions: { value: 'system' | 'dark' | 'light'; icon: LucideIcon; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
];
const downloadForm = useForm<z.infer<typeof downloadFormSchema>>({
resolver: zodResolver(downloadFormSchema),
defaultValues: {
url: '',
},
mode: "onChange",
});
const watchedUrl = downloadForm.watch("url");
const handleDownload = async (url?: string) => {
setIsDownloading(true);
setShowNotRunningAlert(false); // Reset alert status at the beginning
// Create a timeout reference with undefined type
let timeoutId: NodeJS.Timeout | undefined;
try {
const tabs = await new Promise<Browser.tabs.Tab[]>(resolve => {
browser.tabs.query({active: true, currentWindow: true}, resolve);
});
const activeTab = tabs[0];
// Create a race between the actual message and a timeout
const response = await Promise.race([
browser.runtime.sendMessage({
action: 'download',
url: url ?? activeTab.url
}),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error('TIMEOUT'));
}, 5000); // 5 second timeout
})
]);
// If we reach here, the request completed successfully
if (timeoutId) clearTimeout(timeoutId);
if (response) {
console.log('Response from background script:', response);
}
} catch (error) {
console.error("Download failed", error);
// Check if this was a timeout error
if (error instanceof Error && error.message === 'TIMEOUT') {
setShowNotRunningAlert(true);
}
// Clear the timeout if it was some other error
if (timeoutId) clearTimeout(timeoutId);
} finally {
setIsDownloading(false);
}
};
const handleDownloadSubmit = async (values: z.infer<typeof downloadFormSchema>) => {
await handleDownload(values.url);
}
const saveSettings = async <K extends keyof Settings>(key: K, value: Settings[K]) => {
setIsUpdatingSettings(true);
try {
// First, get current settings from storage
const result = await browser.storage.local.get('settings');
const currentSettings = result.settings || {};
// Update with new value
const updatedSettings = {
...currentSettings,
[key]: value
};
// Save to storage
await browser.storage.local.set({ settings: updatedSettings });
// Update state if save was successful
setSettings(prevSettings => ({
...prevSettings,
[key]: value
}));
console.log(`Settings ${key} updated to:`, value);
} catch (error) {
console.error(`Failed to save settings ${key}:`, error);
} finally {
setIsUpdatingSettings(false);
}
};
// loading the settings from storage if available, overwriting the default values when the component mounts
useEffect(() => {
const loadSettings = async () => {
try {
const result = await browser.storage.local.get('settings');
if (result.settings) {
// Merge saved settings with default settings
// Only override keys that exist in saved settings, keeping defaults otherwise
setSettings(prevSettings => ({
...prevSettings,
...result.settings
}));
}
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
setIsLoadingSettings(false);
}
};
loadSettings();
}, []);
// Auto-fill the URL field with the active tab's URL when the component mounts (if autofill is enabled)
useEffect(() => {
console.log({isLoadingSettings, settings});
const autoFillUrl = async () => {
const tabs = await new Promise<Browser.tabs.Tab[]>(resolve => {
browser.tabs.query({active: true, currentWindow: true}, resolve);
});
const activeTab = tabs[0];
if (activeTab && activeTab.url) {
downloadForm.setValue("url", activeTab.url);
await downloadForm.trigger("url");
}
}
if (!isLoadingSettings && settings.autofill_url) {
autoFillUrl();
}
}, [isLoadingSettings, settings.autofill_url]);
// Listen for tab URL changes and update the form value accordingly (if autofill is enabled)
useEffect(() => {
if (isLoadingSettings || !settings.autofill_url) return;
const handleTabUrlChange = async (tabId: number, changeInfo: Browser.tabs.TabChangeInfo) => {
if (changeInfo.status === "complete") {
browser.tabs.get(tabId).then(async (tab) => {
if (tab.active && tab.url) {
downloadForm.setValue("url", tab.url);
await downloadForm.trigger("url");
}
});
}
}
browser.tabs.onUpdated.addListener(handleTabUrlChange);
return () => {
browser.tabs.onUpdated.removeListener(handleTabUrlChange);
}
}, [isLoadingSettings, settings.autofill_url]);
// Update the theme when settings change
useEffect(() => {
const updateTheme = async () => {
setTheme(settings.theme);
}
updateTheme().catch(console.error);
}, [settings.theme]);
return (
<div className="content flex flex-col space-y-4 w-full">
<div className="theme-selection flex items-center justify-center">
<div className={cn('inline-flex gap-1 rounded-lg p-1 bg-muted')}>
{themeOptions.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => saveSettings('theme', value)}
className={cn(
'flex items-center rounded-md px-[0.80rem] py-1.5 transition-colors',
settings.theme === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-xs">{label}</span>
</button>
))}
</div>
</div>
<div className="autofill-url flex items-center justify-center gap-4">
<Switch
id="autofill-url"
checked={settings.autofill_url}
onCheckedChange={(checked) => saveSettings("autofill_url", checked)}
/>
<Label htmlFor="autofill-url">AutoFill Page URL</Label>
</div>
<Form {...downloadForm}>
<form onSubmit={downloadForm.handleSubmit(handleDownloadSubmit)} className="flex flex-col gap-4 w-full" autoComplete="off">
<FormField
control={downloadForm.control}
name="url"
disabled={isDownloading || isLoadingSettings || isUpdatingSettings}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
className="w-full h-28 resize-none text-sm"
placeholder="Enter URL"
{...field}
readOnly={settings.autofill_url}
/>
</FormControl>
<FormMessage />
{showNotRunningAlert && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Host Error</AlertTitle>
<AlertDescription className="text-xs">
Make sure NeoDLP is Installed and Running
</AlertDescription>
</Alert>
)}
</FormItem>
)}
/>
<Button className="w-full cursor-pointer" type="submit" disabled={isDownloading || isLoadingSettings || isUpdatingSettings || !watchedUrl || downloadForm.getFieldState("url").invalid}>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Starting
</>
) : (
<>
<Download className="w-4 h-4" />
Download
</>
)}
</Button>
</form>
</Form>
</div>
)
}

13
pages/layout/root.tsx Normal file
View File

@@ -0,0 +1,13 @@
import Footer from "@/components/footer";
import Header from "@/components/header";
import { Outlet } from "react-router-dom";
export default function RootLayout() {
return (
<div className="flex flex-col min-w-[270px] p-4 space-y-4">
<Header />
<Outlet />
<Footer />
</div>
)
}

BIN
public/icon/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
public/icon/16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/icon/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/icon/48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/icon/96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

11
tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

4
types/settings.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Settings {
theme: 'system' | 'dark' | 'light';
autofill_url: boolean;
}

5
types/websocket.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface WebsocketMessage {
url: string;
command: string;
argument: string;
}

85
wxt.config.ts Normal file
View File

@@ -0,0 +1,85 @@
import path from "path"
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'wxt';
// See https://wxt.dev/api/config.html
export default defineConfig({
modules: ['@wxt-dev/module-react'],
vite: () => ({
plugins: [
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname),
},
},
}),
manifest: ({ browser }) => {
const manifest = {
name: "Neo Downloader Plus",
description: "Neo Downloader Plus Browser Integration",
homepage_url: "https://neodlp.neosubhamoy.com",
version: "0.1.0",
permissions: ["tabs", "storage", "contextMenus", "nativeMessaging"],
}
if (browser === 'chrome') {
return {
...manifest,
key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3XQoL6Qur86lyfRRGYiQ544w9fxJiStWvFJaNqqSlRxkoT0wj8mFVwBjtmUJC6AB31Zb9awELVk1jyo83cPoJjhydHQfk7dpQ3gygp5TdZMjwX5++FpYq5QIV1qyf9BNvGbWG1zHDPqRGC/ZtGaxb9FJyYoFMIUKoiJfuPwup0Iy3dRwJex4mxMobQnFtfoxdMRvjx6XA9v7Fz8QF1t/1lVsx9yOiJPyDDzygrVLR3+r+1Sq7CunK0CWMVPkTRMw243KMZBIDpxrjXaVbasIkZsMwVW0vkqIMXzMZGhUPu1SfflwanAJ5F2Yl0dcO3OxKLYL7szTtLJUD/7PFA2PwIDAQAB",
commands: {
_execute_action: {
suggested_key: {
default: "Alt+Shift+N",
}
},
"neodlp:quick-download": {
description: "Quick Download",
suggested_key: {
default: "Alt+Shift+Q"
}
}
},
}
} else if (browser === 'firefox') {
return {
...manifest,
browser_specific_settings: {
gecko: {
id: "neodlp@neosubhamoy.com"
}
},
commands: {
_execute_browser_action: {
suggested_key: {
default: "Alt+Shift+N",
}
},
"neodlp:quick-download": {
description: "Quick Download",
suggested_key: {
default: "Alt+Shift+Q"
}
}
},
}
} else {
return {
...manifest,
commands: {
_execute_action: {
suggested_key: {
default: "Alt+Shift+N",
}
},
"neodlp:quick-download": {
description: "Quick Download",
suggested_key: {
default: "Alt+Shift+Q"
}
}
},
}
}
}
});