fix: added ability to add multiple blacklists at once and various other fixes to the page

This commit is contained in:
pommee
2025-09-29 20:49:09 +02:00
parent 88764dc0d4
commit c8dcdbfdb8
4 changed files with 652 additions and 281 deletions
+95 -45
View File
@@ -2,7 +2,6 @@ package api
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"goaway/backend/alert"
@@ -10,7 +9,6 @@ import (
"io"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
@@ -18,6 +16,7 @@ import (
func (api *API) registerListsRoutes() {
api.routes.POST("/custom", api.updateCustom)
api.routes.POST("/addList", api.addList)
api.routes.POST("/addLists", api.addLists)
api.routes.GET("/lists", api.getLists)
api.routes.GET("/fetchUpdatedList", api.fetchUpdatedList)
@@ -79,39 +78,20 @@ func (api *API) addList(c *gin.Context) {
Active bool `json:"active"`
}
body, err := io.ReadAll(c.Request.Body)
var newList NewListRequest
err := c.Bind(&newList)
if err != nil {
log.Error("Failed to read request body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request payload"})
return
}
var request NewListRequest
if err := json.Unmarshal(body, &request); err != nil {
log.Error("Failed to parse JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON format"})
err = api.ValidateURLAndName(newList.URL, newList.Name, c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
request.Name = strings.TrimSpace(request.Name)
request.URL = strings.TrimSpace(request.URL)
if request.Name == "" || request.URL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Name and URL are required"})
return
}
if _, err := url.ParseRequestURI(request.URL); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid URL format"})
return
}
if api.Blacklist.URLExists(request.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "List with the same URL already exists"})
return
}
if err := api.Blacklist.FetchAndLoadHosts(request.URL, request.Name); err != nil {
if err = api.Blacklist.FetchAndLoadHosts(newList.URL, newList.Name); err != nil {
log.Error("Failed to fetch and load hosts: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -123,21 +103,21 @@ func (api *API) addList(c *gin.Context) {
return
}
if err := api.Blacklist.AddSource(request.Name, request.URL); err != nil {
if err := api.Blacklist.AddSource(newList.Name, newList.URL); err != nil {
log.Error("Failed to add source: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !request.Active {
if err := api.Blacklist.ToggleBlocklistStatus(request.Name); err != nil {
if !newList.Active {
if err := api.Blacklist.ToggleBlocklistStatus(newList.Name); err != nil {
log.Error("Failed to toggle blocklist status: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to toggle status for " + request.Name})
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to toggle status for " + newList.Name})
return
}
}
_, newList, err := api.Blacklist.GetListStatistics(request.Name)
_, addedList, err := api.Blacklist.GetListStatistics(newList.Name)
if err != nil {
log.Error("Failed to get list statistics: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get list statistics"})
@@ -146,10 +126,72 @@ func (api *API) addList(c *gin.Context) {
api.DNSServer.Audits.CreateAudit(&audit.Entry{
Topic: audit.TopicList,
Message: fmt.Sprintf("New blacklist with name '%s' was added", request.Name),
Message: fmt.Sprintf("New blacklist with name '%s' was added", addedList.Name),
})
c.JSON(http.StatusOK, newList)
c.JSON(http.StatusOK, addedList)
}
func (api *API) addLists(c *gin.Context) {
type NewList struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required,url"`
Active bool `json:"active"`
}
var payload struct {
Lists []NewList `json:"lists" binding:"required,dive"`
}
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var addedList []NewList
var ignoredList []NewList
for _, list := range payload.Lists {
if api.Blacklist.URLExists(list.URL) {
ignoredList = append(ignoredList, list)
continue
}
if err := api.Blacklist.FetchAndLoadHosts(list.URL, list.Name); err != nil {
log.Error("Failed to fetch and load hosts: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := api.Blacklist.PopulateBlocklistCache(); err != nil {
log.Error("Failed to populate blocklist cache: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
if err := api.Blacklist.AddSource(list.Name, list.URL); err != nil {
log.Error("Failed to add source: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !list.Active {
if err := api.Blacklist.ToggleBlocklistStatus(list.Name); err != nil {
log.Error("Failed to toggle blocklist status: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to toggle status for " + list.Name})
return
}
}
addedList = append(addedList, list)
}
if len(addedList) > 0 {
api.DNSServer.Audits.CreateAudit(&audit.Entry{
Topic: audit.TopicList,
Message: fmt.Sprintf("Added %d new blacklists in bulk", len(addedList)),
})
}
c.JSON(http.StatusOK, gin.H{"ignored": ignoredList})
}
func (api *API) updateListName(c *gin.Context) {
@@ -291,23 +333,15 @@ func (api *API) handleUpdateBlockStatus(c *gin.Context) {
}
func (api *API) removeList(c *gin.Context) {
nameParam := c.Query("name")
name := c.Query("name")
url := c.Query("url")
nameBytes, err := base64.StdEncoding.DecodeString(nameParam)
if err != nil {
log.Error("Failed to decode list name: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid list name encoding"})
return
}
name := string(nameBytes)
if !api.Blacklist.NameExists(name, url) {
c.JSON(http.StatusBadRequest, gin.H{"error": "List does not exist"})
return
}
err = api.Blacklist.RemoveSourceAndDomains(name, url)
err := api.Blacklist.RemoveSourceAndDomains(name, url)
if err != nil {
log.Error("%v", err.Error())
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -325,3 +359,19 @@ func (api *API) removeList(c *gin.Context) {
})
c.Status(http.StatusOK)
}
func (api *API) ValidateURLAndName(URL, name string, c *gin.Context) error {
if name == "" || URL == "" {
return fmt.Errorf("name and URL are required")
}
if _, err := url.ParseRequestURI(URL); err != nil {
return fmt.Errorf("invalid URL format")
}
if api.Blacklist.URLExists(URL) {
return fmt.Errorf("list with the same URL already exists")
}
return nil
}
+529 -219
View File
@@ -5,7 +5,10 @@ import {
LinkIcon,
InfoIcon,
CaretDownIcon,
PowerIcon
PowerIcon,
ClipboardTextIcon,
CodeIcon,
TrashIcon
} from "@phosphor-icons/react";
import { useState } from "react";
import { toast } from "sonner";
@@ -17,17 +20,39 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
DialogFooter,
DialogDescription
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { DialogDescription } from "@radix-ui/react-dialog";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ListEntry } from "@/pages/blacklist";
import { PostRequest } from "@/util";
import { Switch } from "@/components/ui/switch";
const RECOMMENDED_SOURCES = [
interface MultiListEntry {
id: string;
name: string;
url: string;
}
interface RecommendedList {
name: string;
url: string;
description: string;
}
interface RecommendedSource {
name: string;
website: string;
description: string;
lists: RecommendedList[];
}
const RECOMMENDED_SOURCES: RecommendedSource[] = [
{
name: "StevenBlack's hosts",
website: "https://github.com/StevenBlack/hosts",
@@ -124,27 +149,45 @@ export function AddList({
const [expandedSources, setExpandedSources] = useState<Set<string>>(
new Set()
);
const [isMultipleTab, setIsMultipleTab] = useState(false);
const [listActive, setListActive] = useState(true);
const [multiEntries, setMultiEntries] = useState<MultiListEntry[]>([]);
const toggleSourceExpansion = (sourceName: string) => {
const newExpanded = new Set(expandedSources);
if (newExpanded.has(sourceName)) {
newExpanded.delete(sourceName);
} else {
newExpanded.add(sourceName);
const isValidUrl = (urlString: string): boolean => {
try {
new URL(urlString);
return true;
} catch {
return false;
}
setExpandedSources(newExpanded);
};
const handleSave = async () => {
const toggleSourceExpansion = (sourceName: string) => {
setExpandedSources((prev) => {
const next = new Set(prev);
if (next.has(sourceName)) {
next.delete(sourceName);
} else {
next.add(sourceName);
}
return next;
});
};
const resetForm = () => {
setListName("");
setUrl("");
setMultiEntries([]);
setModalOpen(false);
};
const handleSaveSingle = async () => {
if (!listName.trim() || !url.trim()) {
toast.error("Please fill in both list name and URL");
return;
}
try {
new URL(url);
} catch {
if (!isValidUrl(url)) {
toast.error("Please enter a valid URL");
return;
}
@@ -169,9 +212,7 @@ export function AddList({
onListAdded(newList);
toast.success(`${listName} has been added successfully!`);
setModalOpen(false);
setListName("");
setUrl("");
resetForm();
} else {
toast.error("Failed to add list. Please try again.");
}
@@ -184,13 +225,112 @@ export function AddList({
}
};
const handleCancel = () => {
setModalOpen(false);
setListName("");
setUrl("");
const handleSaveMultiple = async () => {
const validEntries = multiEntries.filter(
(e) => e.name.trim() && e.url.trim()
);
if (validEntries.length === 0) {
toast.error("Please fill in at least one list name and URL");
return;
}
for (const entry of validEntries) {
if (!isValidUrl(entry.url)) {
toast.error(`Invalid URL: ${entry.url}`);
return;
}
}
setIsSaving(true);
try {
const [code, response] = await PostRequest("addLists", {
lists: validEntries.map((e) => ({
name: e.name.trim(),
url: e.url.trim(),
active: listActive
}))
});
if (code === 200) {
const ignoredCount = response.ignored
? Object.keys(response.ignored).length
: 0;
if (ignoredCount > 0) {
toast.warning(`${ignoredCount} lists were ignored`, {
description: "Reason: Lists already exist"
});
} else {
toast.success(`${validEntries.length} lists added successfully!`);
}
resetForm();
} else {
toast.error("Failed to add lists. Please try again.");
}
} catch (error) {
toast.error("An error occurred while adding lists", {
description: `${error}`
});
} finally {
setIsSaving(false);
}
};
const isFormValid = listName.trim() && url.trim();
const addMultiEntry = () => {
setMultiEntries((prev) => [
...prev,
{ id: Date.now().toString(), name: "", url: "" }
]);
};
const updateMultiEntry = (
index: number,
field: "name" | "url",
value: string
) => {
setMultiEntries((prev) => {
const updated = [...prev];
updated[index][field] = value;
return updated;
});
};
const removeMultiEntry = (index: number) => {
setMultiEntries((prev) => prev.filter((_, i) => i !== index));
};
const parseBulkUrls = () => {
const textArea = document.getElementById(
"bulk-urls"
) as HTMLTextAreaElement;
if (!textArea?.value.trim()) return;
const urls = textArea.value
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line);
const newEntries: MultiListEntry[] = urls.map((url) => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: "",
url
}));
setMultiEntries((prev) => [...prev, ...newEntries]);
textArea.value = "";
};
const validMultiEntriesCount = multiEntries.filter(
(e) => e.name.trim() && e.url.trim()
).length;
const isSingleFormValid = listName.trim() && url.trim();
const isSaveDisabled = isMultipleTab
? validMultiEntriesCount === 0
: !isSingleFormValid;
return (
<div className="mb-5">
@@ -200,7 +340,7 @@ export function AddList({
<SpinnerGapIcon
className="animate-spin text-primary mt-0.5 mr-2"
size={24}
/>{" "}
/>
Adding list...
</div>
<div className="text-sm text-muted-foreground text-center">
@@ -218,11 +358,8 @@ export function AddList({
</DialogTrigger>
<DialogContent className="sm:max-w-250 max-h-[90vh] overflow-y-auto">
<DialogHeader className="space-y-3 pb-4">
<DialogHeader className="space-y-3 pb-2">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full">
<ListIcon size={24} className="text-primary" />
</div>
<div>
<DialogTitle className="text-xl font-semibold">
Add New Blocklist
@@ -234,205 +371,378 @@ export function AddList({
</div>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-4 p-4 rounded-lg border">
<div className="space-y-2">
<Label
htmlFor="name"
className="text-sm font-medium text-muted-foreground flex items-center gap-2"
<div className="flex w-full flex-col gap-6">
<Tabs
defaultValue="single"
onValueChange={(val) => setIsMultipleTab(val === "multiple")}
>
<TabsList className="w-full mb-2 bg-transparent gap-5">
<TabsTrigger
value="single"
className="cursor-pointer hover:bg-muted-foreground/10"
>
<ListIcon size={16} />
List Name
</Label>
<Input
id="name"
value={listName}
placeholder="Enter a descriptive name for this list"
onChange={(e) => setListName(e.target.value)}
className="border-2"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="url"
className="text-sm font-medium text-muted-foreground flex items-center gap-2"
<ClipboardTextIcon />
Single
</TabsTrigger>
<TabsTrigger
value="multiple"
className="cursor-pointer hover:bg-muted-foreground/10"
>
<LinkIcon size={16} />
List URL
</Label>
<Input
id="url"
value={url}
placeholder="https://example.com/blocklist.txt"
onChange={(e) => setUrl(e.target.value)}
className="border-2 font-mono text-sm"
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
Enter the direct URL to a hosts file or domain list
</p>
</div>
<CodeIcon />
Multiple
</TabsTrigger>
</TabsList>
<div className="space-y-2">
<Label htmlFor="active" className="text-muted-foreground">
<PowerIcon size={16} />
List active
</Label>
<Switch checked={listActive} onCheckedChange={setListActive} />
</div>
</div>
<TabsContent value="single">
<div className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="name"
className="text-sm font-medium text-muted-foreground flex items-center gap-2"
>
<ListIcon size={16} />
List Name
</Label>
<Input
id="name"
value={listName}
placeholder="Enter a descriptive name for this list"
onChange={(e) => setListName(e.target.value)}
className="border-2"
disabled={isSaving}
/>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<InfoIcon size={16} className="text-primary" />
<span className="text-sm font-medium">
Popular Blocklist Sources
</span>
</div>
<div className="space-y-2">
<Label
htmlFor="url"
className="text-sm font-medium text-muted-foreground flex items-center gap-2"
>
<LinkIcon size={16} />
List URL
</Label>
<Input
id="url"
value={url}
placeholder="https://example.com/blocklist.txt"
onChange={(e) => setUrl(e.target.value)}
className="border-2 font-mono text-sm"
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
Enter the direct URL to a hosts file or domain list
</p>
</div>
<div className="border rounded-lg overflow-hidden">
<button
onClick={() => setShowSources(!showSources)}
className="w-full p-3 hover:bg-accent transition-colors flex items-center justify-between text-left border-b cursor-pointer"
disabled={isSaving}
>
<span className="text-sm font-medium">
Browse Recommended Lists ({RECOMMENDED_SOURCES.length})
</span>
<CaretDownIcon
size={16}
className={`text-gray-500 transition-transform duration-300 ${
showSources ? "rotate-0" : "-rotate-90"
}`}
/>
</button>
<div
className={`transition-all duration-300 ease-in-out overflow-hidden ${
showSources
? "max-h-[1000px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="divide-y">
{RECOMMENDED_SOURCES.map((source) => (
<div
key={source.name}
className="border-b last:border-b-0"
>
<button
onClick={() => toggleSourceExpansion(source.name)}
className="w-full p-3 hover:bg-accent transition-colors flex items-center justify-between text-left cursor-pointer"
disabled={isSaving}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">{source.name}</h4>
<a
href={source.website}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-blue-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<LinkIcon size={14} />
</a>
</div>
<p className="text-xs text-muted-foreground mt-1">
{source.description}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground bg-accent px-2 py-1 rounded-full">
{source.lists.length} lists
</span>
<CaretDownIcon
size={16}
className={`text-muted-foreground transition-transform duration-300 ${
expandedSources.has(source.name)
? "rotate-0"
: "-rotate-90"
}`}
/>
</div>
</button>
<div
className={`transition-all duration-300 ease-in-out overflow-hidden ${
expandedSources.has(source.name)
? "max-h-[800px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-1 transform transition-transform duration-300">
{source.lists.map((list, index) => (
<div
key={list.url}
className={`p-3 bg-accent mx-3 mb-3 rounded border-l-4 border-primary transform transition-all duration-300 ${
expandedSources.has(source.name)
? "translate-y-0 opacity-100"
: "translate-y-[-10px] opacity-0"
}`}
style={{
transitionDelay: expandedSources.has(
source.name
)
? `${index * 50}ms`
: "0ms"
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h5 className="font-medium text-sm">
{list.name}
</h5>
<p className="text-xs text-muted-foreground mt-1">
{list.description}
</p>
<a
href={list.url}
target="_blank"
className="text-xs text-blue-500 mt-1 font-mono truncate hover:underline transition-colors"
>
{list.url}
</a>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setListName(list.name);
setUrl(list.url);
}}
disabled={isSaving}
className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors"
>
Use This
</Button>
</div>
</div>
))}
</div>
</div>
</div>
))}
<div className="space-y-2">
<Label
htmlFor="active"
className="text-muted-foreground flex items-center gap-2"
>
<PowerIcon size={16} />
List active
</Label>
<Switch
id="active"
checked={listActive}
onCheckedChange={setListActive}
disabled={isSaving}
/>
</div>
</div>
</div>
<div className="text-xs text-muted-foreground ml-1">
<strong>Tip:</strong> Expand the list above to see popular
blocklist sources, or manually enter your own list details in
the form.
</div>
</div>
<div className="space-y-3 mt-5">
<div className="flex items-center gap-2">
<InfoIcon size={16} className="text-primary" />
<span className="text-sm font-medium">
Popular Blocklist Sources
</span>
</div>
<div className="border rounded-lg overflow-hidden">
<button
onClick={() => setShowSources(!showSources)}
className="w-full p-3 hover:bg-accent transition-colors flex items-center justify-between text-left border-b cursor-pointer"
disabled={isSaving}
>
<span className="text-sm font-medium">
Browse Recommended Lists ({RECOMMENDED_SOURCES.length})
</span>
<CaretDownIcon
size={16}
className={`text-muted-foreground transition-transform duration-300 ${
showSources ? "rotate-0" : "-rotate-90"
}`}
/>
</button>
<div
className={`transition-all duration-300 ease-in-out overflow-hidden ${
showSources
? "max-h-[1000px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="divide-y">
{RECOMMENDED_SOURCES.map((source) => (
<div
key={source.name}
className="border-b last:border-b-0"
>
<button
onClick={() => toggleSourceExpansion(source.name)}
className="w-full p-3 hover:bg-accent transition-colors flex items-center justify-between text-left cursor-pointer"
disabled={isSaving}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">{source.name}</h4>
<a
href={source.website}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-blue-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<LinkIcon size={14} />
</a>
</div>
<p className="text-xs text-muted-foreground mt-1">
{source.description}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground bg-accent px-2 py-1 rounded-full">
{source.lists.length} lists
</span>
<CaretDownIcon
size={16}
className={`text-muted-foreground transition-transform duration-300 ${
expandedSources.has(source.name)
? "rotate-0"
: "-rotate-90"
}`}
/>
</div>
</button>
<div
className={`transition-all duration-300 ease-in-out overflow-hidden ${
expandedSources.has(source.name)
? "max-h-[800px] opacity-100"
: "max-h-0 opacity-0"
}`}
>
<div className="p-1 transform transition-transform duration-300">
{source.lists.map((list, index) => (
<div
key={list.url}
className={`p-3 bg-accent mx-3 mb-3 rounded border-l-4 border-primary transform transition-all duration-300 ${
expandedSources.has(source.name)
? "translate-y-0 opacity-100"
: "translate-y-[-10px] opacity-0"
}`}
style={{
transitionDelay: expandedSources.has(
source.name
)
? `${index * 50}ms`
: "0ms"
}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h5 className="font-medium text-sm">
{list.name}
</h5>
<p className="text-xs text-muted-foreground mt-1">
{list.description}
</p>
<a
href={list.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 mt-1 font-mono truncate hover:underline transition-colors"
>
{list.url}
</a>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setListName(list.name);
setUrl(list.url);
}}
disabled={isSaving}
className="text-xs hover:bg-primary hover:text-primary-foreground transition-colors"
>
Use This
</Button>
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="text-xs text-muted-foreground ml-1">
<strong>Tip:</strong> Expand the list above to see popular
blocklist sources, or manually enter your own list details
in the form.
</div>
</div>
</TabsContent>
<TabsContent value="multiple">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ListIcon size={16} />
Blocklist Entries
</Label>
<p className="text-xs text-muted-foreground">
Add multiple blocklists with custom names and URLs
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={addMultiEntry}
disabled={isSaving}
className="shrink-0"
>
<PlusIcon size={16} className="mr-1" />
Add Entry
</Button>
</div>
<ScrollArea className="max-h-94 overflow-auto">
<div className="overflow-y-auto">
{multiEntries.map((entry, index) => (
<Card
key={entry.id}
className="bg-transparent border-none py-2 hover:bg-muted-foreground/10 rounded-sm"
>
<CardContent className="pl-0 pr-2">
<div className="flex items-start gap-3">
<div className="flex gap-2 w-full">
<Input
value={entry.name}
onChange={(e) =>
updateMultiEntry(
index,
"name",
e.target.value
)
}
placeholder="Enter list name"
className="h-9"
disabled={isSaving}
/>
<Input
value={entry.url}
onChange={(e) =>
updateMultiEntry(
index,
"url",
e.target.value
)
}
placeholder="https://example.com/blocklist.txt"
className="h-9 font-mono text-sm w-full"
disabled={isSaving}
/>
</div>
{multiEntries.length > 1 && (
<Button
variant="outline"
size="sm"
onClick={() => removeMultiEntry(index)}
disabled={isSaving}
className="h-9 w-9 hover:bg-destructive/50"
>
<TrashIcon size={16} />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</ScrollArea>
<div className="space-y-2">
<Label className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ClipboardTextIcon size={16} />
Paste URLs
</Label>
<p className="text-xs text-muted-foreground">
Paste multiple URLs (one per line) and click "Parse URLs"
to create entries.
</p>
<textarea
id="bulk-urls"
rows={3}
placeholder="Paste one URL per line&#10;https://example/blockthis.txt&#10;..."
className="w-full border-2 rounded-md p-2 font-mono text-sm resize-y"
disabled={isSaving}
/>
<Button
variant="outline"
size="sm"
onClick={parseBulkUrls}
disabled={isSaving}
>
Parse URLs
</Button>
</div>
<div className="space-y-3 pt-4 border-t">
<div className="space-y-2">
<Label
htmlFor="multi-active"
className="text-sm font-medium text-muted-foreground flex items-center gap-2"
>
<PowerIcon size={16} />
All lists active
</Label>
<Switch
id="multi-active"
checked={listActive}
onCheckedChange={setListActive}
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">
This setting will be applied to all lists when added
</p>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-4">
{isMultipleTab && multiEntries.length > 0 && (
<div className="py-1 px-10 border-b-2 border-p-2 border-primary">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Ready to add:</span>
<span className="font-medium ml-1">
{validMultiEntriesCount} of {multiEntries.length} lists
</span>
</div>
</div>
)}
<Button
variant="outline"
onClick={handleCancel}
onClick={resetForm}
disabled={isSaving}
className="order-2 sm:order-1 transition-colors"
>
@@ -440,8 +750,8 @@ export function AddList({
</Button>
<Button
variant="default"
onClick={handleSave}
disabled={isSaving || !isFormValid}
onClick={isMultipleTab ? handleSaveMultiple : handleSaveSingle}
disabled={isSaving || isSaveDisabled}
className="order-1 sm:order-2 shadow-sm hover:shadow-md transition-all duration-200"
>
{isSaving ? (
@@ -452,7 +762,7 @@ export function AddList({
) : (
<>
<PlusIcon className="mr-2 h-4 w-4" />
Add List
{isMultipleTab ? "Add Lists" : "Add List"}
</>
)}
</Button>
+3 -3
View File
@@ -115,10 +115,10 @@ export function CardDetails(
const deleteList = async () => {
setDeletingList(true);
try {
// Base64 encode to support special characters
const base64EncodedListName = btoa(listEntry.name);
const [code, response] = await DeleteRequest(
`list?name=${base64EncodedListName}&url=${listEntry.url}`,
`list?name=${encodeURIComponent(
listEntry.name
)}&url=${encodeURIComponent(listEntry.url)}`,
null
);
+25 -14
View File
@@ -110,36 +110,47 @@ export function Blacklist() {
}
};
const handleSelect = (name: string) => {
const handleSelect = (name: string, url: string) => {
setSelected((prev) => {
const key = `${name}|${url}`;
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const handleRemoveSelected = async () => {
for (const name of selected) {
setDeleting((prev) => new Set(prev).add(name));
await DeleteRequest(`list?name=${encodeURIComponent(name)}`, null);
for (const key of selected) {
const [name, url] = key.split("|");
setDeleting((prev) => new Set(prev).add(key));
await DeleteRequest(
`list?name=${encodeURIComponent(name)}&url=${encodeURIComponent(url)}`,
null
);
setTimeout(() => {
setFadingOut((prev) => new Set(prev).add(name));
setFadingOut((prev) => new Set(prev).add(key));
setTimeout(() => {
setLists((prev) => prev.filter((list) => list.name !== name));
setLists((prev) =>
prev.filter((list) => !(list.name === name && list.url === url))
);
setDeleting((prev) => {
const next = new Set(prev);
next.delete(name);
next.delete(key);
return next;
});
setFadingOut((prev) => {
const next = new Set(prev);
next.delete(name);
next.delete(key);
return next;
});
}, 400);
}, 0);
}
setSelected(new Set());
};
@@ -242,11 +253,11 @@ export function Blacklist() {
onDelete={() => handleDelete(list.name, list.url)}
onRename={handleRename}
editMode={editMode}
selected={selected.has(list.name)}
onSelect={() => handleSelect(list.name)}
onSelect={() => handleSelect(list.name, list.url)}
selected={selected.has(`${list.name}|${list.url}`)}
updating={updating.has(list.name)}
deleting={deleting.has(list.name)}
fadingOut={fadingOut.has(list.name)}
deleting={deleting.has(`${list.name}|${list.url}`)}
fadingOut={fadingOut.has(`${list.name}|${list.url}`)}
/>
))}
</div>