Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
1.0.0
- Feat: Complete architectural refactor to Bookmark Manager PWA
- Feat: Replace Bootstrap/SCSS stack with TailwindCSS 4 (class-based dark mode via @custom-variant)
- Feat: Add Zustand store for global state — resources, favorites, statuses, search, filters
- Feat: Add Fuse.js fuzzy search across title, description, tags, URL, and category
- Feat: Add vite-plugin-pwa — installable, offline-capable with auto-updating service worker
- Feat: Migrate resources.json to new schema (id, title, url, description, category, tags, createdAt)
- Feat: Remove pendings.json — dynamic state (favorites, statuses) persisted in localStorage
- Feat: Add status cycling per resource — Pendiente → Consumido → Referencia → none
- Feat: Add Sidebar navigation with All / Favorites / Pending / Consumed / Categories
- Feat: Add Topbar with global fuzzy search input and dark mode toggle
- Feat: Add ResourceCard component with favicon, domain, tags, category, status, and action buttons
- Refactor: New modular architecture — app/, types/, services/, store/, hooks/, components/, utils/
- Refactor: Service layer — storageService, searchService, resourceService (repository pattern)
- Refactor: Custom hooks — useResources, useFavorites, useStatuses, useSearch
- Refactor: Remove unused dependencies — axios, bootstrap, react-bootstrap, formik, dotenv
- Fix: Search reactivity — useResources now subscribes to all relevant Zustand slices
- Fix: Dark mode toggle — applyDarkMode() syncs classList + localStorage atomically
- Refactor(tests): Migrate all tests to src/__tests__ mirroring src structure
- Test: 49 unit tests — storageService, searchService, useFavorites, useStatuses, ResourceCard, SearchBar
- Feat: Add check-links.sh script — parallel URL availability checker with broken-link report
- Chore: Update path aliases — @components, @hooks, @store, @services, @types, @utils
- Chore: Update README with new stack, architecture, data model, and usage docs

0.4.0
- Test: Add Vitest + Testing Library unit test infrastructure
- Test: Configure jsdom environment, coverage with v8 provider
Expand Down
80 changes: 60 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
# WebResources
# Bookmark Manager PWA

A curated collection of web development resources, searchable in real time with dark mode support.
A fast, offline-capable PWA to explore and manage a curated collection of 1000+ web bookmarks. Built with React, TypeScript, TailwindCSS, and Zustand.

**Deploy** → https://webresources.netlify.app

## Features

- **Fuzzy search** — search across titles, descriptions, tags, URLs, and categories with Fuse.js
- **Favorites** — mark resources as favorites, persisted in localStorage
- **Status tracking** — cycle through states: Pendiente → Consumido → Referencia
- **Category filtering** — browse resources by category via sidebar navigation
- **Dark mode** — toggle between light and dark themes
- **PWA** — installable, works offline with service worker
- **Virtualization-ready** — designed to handle thousands of resources

## Stack

| Layer | Technology |
|---|---|
| UI | React 19 + TypeScript (strict) |
| Build | Vite 7 |
| Styling | SCSS + Bootstrap + React Bootstrap |
| Routing | React Router v6 |
| Forms | Formik |
| HTTP | Axios |
| Icons | React Icons |
| Styling | TailwindCSS 4 |
| State | Zustand |
| Search | Fuse.js |
| Routing | React Router v7 |
| PWA | vite-plugin-pwa |
| Testing | Vitest + React Testing Library |
| Package manager | pnpm |

## Getting started
Expand All @@ -40,26 +51,55 @@ pnpm dev

```
src/
├── components/ # UI components (Building, FilterBar, Footer, Navbar, ResourceCard, Toggle)
├── context/ # DarkModeContext
├── hooks/ # useResources
├── layouts/ # PublicLayout
├── models/ # Resource, ResourceDTO
├── pages/ # Dashboard, NotFound
├── data/ # resources.json
└── test/ # setup.ts, utils.tsx
├── app/ # App.tsx (main application shell)
├── components/ # UI components (ResourceCard, SearchBar, Sidebar, Topbar, TagList, CategoryList)
├── hooks/ # Custom hooks (useResources, useFavorites, useStatuses, useSearch)
├── pages/ # Pages (Dashboard, NotFound)
├── services/ # Business logic (storageService, searchService, resourceService)
├── store/ # Zustand store (resourceStore)
├── types/ # TypeScript types (Resource, ResourceStatus, UserState)
├── utils/ # Utility functions (url, date)
├── data/ # resources.json (read-only source of truth)
└── test/ # Test setup and utilities
```

## Architecture

- **SOLID** — each component and hook has a single responsibility
- **Custom hooks** — `useResources` owns resource state (data parsing, filtering)
- **Context API** — `DarkModeContext` persists dark mode preference in `localStorage`
- **Path aliases** — `@components`, `@assets`, `@models`, `@hooks`, `@context`
- **SOLID** — each component, hook, and service has a single responsibility
- **Service layer** — `storageService` (localStorage), `searchService` (Fuse.js), `resourceService` (JSON data)
- **Zustand store** — global state for resources, favorites, statuses, search, and filters
- **Custom hooks** — `useResources`, `useFavorites`, `useStatuses`, `useSearch`
- **Path aliases** — `@components`, `@hooks`, `@store`, `@services`, `@types`, `@utils`

## Data model

Resources are stored in `src/data/resources.json` (read-only):

```ts
interface Resource {
id: string
title: string
url: string
description?: string
category: string
tags: string[]
createdAt: string
}
```

User state (favorites, statuses) is persisted in `localStorage`:

```ts
type ResourceStatus = "pending" | "consumed" | "reference"
```

## Testing

Unit tests with [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/), 100% coverage on all source files.
49 unit tests with [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/):

- **Services** — storageService, searchService
- **Hooks** — useFavorites, useStatuses
- **Components** — ResourceCard, SearchBar

| Command | Description |
|---|---|
Expand Down
164 changes: 164 additions & 0 deletions check-links.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
# check-links.sh — Verifica disponibilidad de URLs en resources.json
# Uso: ./check-links.sh [--timeout N] [--parallel N]

set -euo pipefail

RESOURCES_FILE="$(dirname "$0")/src/data/resources.json"
TIMEOUT=10
PARALLEL=10
BROKEN=()
TOTAL=0
CHECKED=0

# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--timeout) TIMEOUT="$2"; shift 2 ;;
--parallel) PARALLEL="$2"; shift 2 ;;
*) echo "Uso: $0 [--timeout N] [--parallel N]"; exit 1 ;;
esac
done

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
RESET='\033[0m'

# Check dependencies
for cmd in curl python3 jq; do
if ! command -v "$cmd" &>/dev/null; then
FALLBACK=python3; break
fi
done

echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e "${BLUE} Bookmark Link Checker${RESET}"
echo -e "${BLUE} Archivo: ${RESOURCES_FILE}${RESET}"
echo -e "${BLUE} Timeout: ${TIMEOUT}s | Paralelo: ${PARALLEL} workers${RESET}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo ""

if [[ ! -f "$RESOURCES_FILE" ]]; then
echo -e "${RED}ERROR: No se encontró el archivo $RESOURCES_FILE${RESET}"
exit 1
fi

# Extract id, title, url using python3 (avoids jq dependency)
ENTRIES=$(python3 -c "
import json, sys
with open('$RESOURCES_FILE') as f:
data = json.load(f)
for r in data:
title = r.get('title', '').replace('|', ' ')
url = r.get('url', '')
rid = r.get('id', '')
if url:
print(f\"{rid}|{title}|{url}\")
")

TOTAL=$(echo "$ENTRIES" | wc -l | tr -d ' ')
echo -e " Verificando ${TOTAL} enlaces...\n"

TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT

# Function to check a single URL
check_url() {
local rid="$1"
local title="$2"
local url="$3"
local result_file="$4"

http_code=$(curl \
--silent \
--output /dev/null \
--write-out "%{http_code}" \
--max-time "$TIMEOUT" \
--connect-timeout "$TIMEOUT" \
--location \
--user-agent "Mozilla/5.0 (compatible; LinkChecker/1.0)" \
"$url" 2>/dev/null) || http_code="000"

if [[ "$http_code" =~ ^(200|201|202|203|204|301|302|307|308)$ ]]; then
echo "OK|${rid}|${title}|${url}|${http_code}" >> "$result_file"
else
echo "BROKEN|${rid}|${title}|${url}|${http_code}" >> "$result_file"
fi
}

export -f check_url
export TIMEOUT

RESULT_FILE="$TMP_DIR/results.txt"
touch "$RESULT_FILE"

# Run checks in parallel using a simple semaphore
count=0
pids=()

while IFS='|' read -r rid title url; do
check_url "$rid" "$title" "$url" "$RESULT_FILE" &
pids+=($!)
count=$((count + 1))

# Limit parallelism
if (( ${#pids[@]} >= PARALLEL )); then
wait "${pids[0]}"
pids=("${pids[@]:1}")
fi

# Progress indicator
printf "\r Progreso: %d / %d" "$count" "$TOTAL"
done <<< "$ENTRIES"

# Wait for remaining jobs
for pid in "${pids[@]}"; do
wait "$pid"
done

echo -e "\r Progreso: ${TOTAL} / ${TOTAL} ✓\n"

# Count results
OK_COUNT=$(grep -c "^OK|" "$RESULT_FILE" 2>/dev/null || echo 0)
BROKEN_COUNT=$(grep -c "^BROKEN|" "$RESULT_FILE" 2>/dev/null || echo 0)

# Summary
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"
echo -e " ${GREEN}✓ Disponibles:${RESET} ${OK_COUNT}"
echo -e " ${RED}✗ Rotos/Inaccesibles:${RESET} ${BROKEN_COUNT}"
echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}"

if [[ "$BROKEN_COUNT" -gt 0 ]]; then
echo ""
echo -e "${YELLOW} ENLACES ROTOS O INACCESIBLES:${RESET}"
echo ""
printf " %-5s %-6s %-35s %s\n" "ID" "HTTP" "Título" "URL"
printf " %s\n" "──────────────────────────────────────────────────────────────"
while IFS='|' read -r status rid title url http_code; do
printf " %-5s ${RED}%-6s${RESET} %-35s %s\n" "$rid" "$http_code" "${title:0:35}" "$url"
done < <(grep "^BROKEN|" "$RESULT_FILE")
echo ""
echo -e " ${YELLOW}Para eliminar un recurso, edita manualmente: src/data/resources.json${RESET}"
echo ""
fi

# Save broken links report
if [[ "$BROKEN_COUNT" -gt 0 ]]; then
REPORT_FILE="broken-links-$(date +%Y%m%d-%H%M%S).txt"
{
echo "Reporte de enlaces rotos — $(date)"
echo "================================================"
grep "^BROKEN|" "$RESULT_FILE" | while IFS='|' read -r status rid title url http_code; do
echo "ID: $rid | HTTP: $http_code | $title"
echo " $url"
echo ""
done
} > "$REPORT_FILE"
echo -e " Reporte guardado en: ${BLUE}${REPORT_FILE}${RESET}"
echo ""
fi

exit $(( BROKEN_COUNT > 0 ? 1 : 0 ))
Loading
Loading