✨ Features: - App iOS completa para leer manga sin publicidad - Scraper con WKWebView para manhwaweb.com - Sistema de descargas offline - Lector con zoom y navegación - Favoritos y progreso de lectura - Compatible con iOS 15+ y Sideloadly/3uTools 📦 Contenido: - Backend Node.js con Puppeteer (opcional) - App iOS con SwiftUI - Scraper de capítulos e imágenes - Sistema de almacenamiento local - Testing completo - Documentación exhaustiva 🧪 Prueba: Capítulo 789 de One Piece descargado exitosamente - 21 páginas descargadas - 4.68 MB total - URLs verificadas y funcionales 🎉 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
265 lines
7.7 KiB
Swift
265 lines
7.7 KiB
Swift
import SwiftUI
|
|
|
|
struct ContentView: View {
|
|
@StateObject private var viewModel = MangaListViewModel()
|
|
|
|
var body: some View {
|
|
NavigationView {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.mangas.isEmpty {
|
|
loadingView
|
|
} else if viewModel.mangas.isEmpty {
|
|
emptyStateView
|
|
} else {
|
|
mangaListView
|
|
}
|
|
}
|
|
.navigationTitle("MangaReader")
|
|
.searchable(text: $viewModel.searchText, prompt: "Buscar manga...")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Menu {
|
|
Button {
|
|
viewModel.filter = .all
|
|
} label: {
|
|
Label("Todos", systemImage: viewModel.filter == .all ? "checkmark" : "")
|
|
}
|
|
|
|
Button {
|
|
viewModel.filter = .favorites
|
|
} label: {
|
|
Label("Favoritos", systemImage: viewModel.filter == .favorites ? "checkmark" : "")
|
|
}
|
|
|
|
Button {
|
|
viewModel.filter = .downloaded
|
|
} label: {
|
|
Label("Descargados", systemImage: viewModel.filter == .downloaded ? "checkmark" : "")
|
|
}
|
|
} label: {
|
|
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var loadingView: some View {
|
|
VStack(spacing: 20) {
|
|
ProgressView()
|
|
.scaleEffect(1.5)
|
|
Text("Cargando mangas...")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
private var emptyStateView: some View {
|
|
VStack(spacing: 20) {
|
|
Image(systemName: "book.closed")
|
|
.font(.system(size: 60))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Agrega un manga manualmente")
|
|
.font(.headline)
|
|
|
|
Text("Ingresa el slug del manga (ej: one-piece_1695365223767)")
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
|
|
HStack {
|
|
TextField("Slug del manga", text: $viewModel.newMangaSlug)
|
|
.textFieldStyle(.roundedBorder)
|
|
.autocapitalization(.none)
|
|
|
|
Button("Agregar") {
|
|
Task {
|
|
await viewModel.addManga(viewModel.newMangaSlug)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
private var mangaListView: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 16) {
|
|
ForEach(viewModel.filteredMangas) { manga in
|
|
NavigationLink(destination: MangaDetailView(manga: manga)) {
|
|
MangaRowView(manga: manga)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.refreshable {
|
|
await viewModel.loadMangas()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MangaRowView: View {
|
|
let manga: Manga
|
|
@StateObject private var storage = StorageService.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
// Cover image placeholder
|
|
AsyncImage(url: URL(string: manga.coverImage ?? "")) { image in
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} placeholder: {
|
|
Rectangle()
|
|
.fill(Color.gray.opacity(0.2))
|
|
.overlay(
|
|
Image(systemName: "book.closed")
|
|
.foregroundColor(.gray)
|
|
)
|
|
}
|
|
.frame(width: 60, height: 80)
|
|
.cornerRadius(8)
|
|
.clipped()
|
|
|
|
// Manga info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(manga.title)
|
|
.font(.headline)
|
|
.lineLimit(2)
|
|
|
|
Text(manga.displayStatus)
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.background(statusColor.opacity(0.2))
|
|
.foregroundColor(statusColor)
|
|
.cornerRadius(4)
|
|
|
|
if !manga.genres.isEmpty {
|
|
Text(manga.genres.prefix(3).joined(separator: ", "))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
if storage.isFavorite(mangaSlug: manga.slug) {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "heart.fill")
|
|
.foregroundColor(.red)
|
|
.font(.caption)
|
|
Text("Favorito")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: "chevron.right")
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.padding()
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(Color(.systemBackground))
|
|
.shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2)
|
|
)
|
|
}
|
|
|
|
private var statusColor: Color {
|
|
switch manga.status {
|
|
case "PUBLICANDOSE":
|
|
return .green
|
|
case "FINALIZADO":
|
|
return .blue
|
|
case "EN_PAUSA", "EN_ESPERA":
|
|
return .orange
|
|
default:
|
|
return .gray
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ViewModel
|
|
@MainActor
|
|
class MangaListViewModel: ObservableObject {
|
|
@Published var mangas: [Manga] = []
|
|
@Published var isLoading = false
|
|
@Published var searchText = ""
|
|
@Published var filter: MangaFilter = .all
|
|
@Published var newMangaSlug = ""
|
|
|
|
private let storage = StorageService.shared
|
|
private let scraper = ManhwaWebScraper.shared
|
|
|
|
var filteredMangas: [Manga] {
|
|
var result = mangas
|
|
|
|
// Apply search filter
|
|
if !searchText.isEmpty {
|
|
result = result.filter { manga in
|
|
manga.title.localizedCaseInsensitiveContains(searchText)
|
|
}
|
|
}
|
|
|
|
// Apply category filter
|
|
switch filter {
|
|
case .favorites:
|
|
result = result.filter { storage.isFavorite(mangaSlug: $0.slug) }
|
|
case .downloaded:
|
|
result = result.filter { manga in
|
|
storage.getDownloadedChapters().contains { $0.mangaSlug == manga.slug }
|
|
}
|
|
case .all:
|
|
break
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func loadMangas() async {
|
|
isLoading = true
|
|
|
|
// Cargar mangas guardados
|
|
let favorites = storage.getFavorites()
|
|
|
|
// Para demo, agregar One Piece por defecto
|
|
if mangas.isEmpty {
|
|
await addManga("one-piece_1695365223767")
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
func addManga(_ slug: String) async {
|
|
guard !slug.isEmpty else { return }
|
|
|
|
do {
|
|
let manga = try await scraper.scrapeMangaInfo(mangaSlug: slug)
|
|
|
|
if !mangas.contains(where: { $0.slug == manga.slug }) {
|
|
mangas.append(manga)
|
|
}
|
|
|
|
newMangaSlug = ""
|
|
} catch {
|
|
print("Error adding manga: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum MangaFilter {
|
|
case all
|
|
case favorites
|
|
case downloaded
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|