mirror of
https://github.com/pommee/goaway.git
synced 2026-05-07 01:00:02 -05:00
fix: added ability to add multiple blacklists at once and various other fixes to the page
This commit is contained in:
+95
-45
@@ -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
@@ -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 https://example/blockthis.txt ..."
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user