Initial commit: MangaReader iOS App
✨ 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>
This commit is contained in:
264
ios-app/Sources/Views/ContentView.swift
Normal file
264
ios-app/Sources/Views/ContentView.swift
Normal file
@@ -0,0 +1,264 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user