Series to Collection Function
This commit is contained in:
@@ -5,6 +5,7 @@ import com.storycove.entity.Collection;
|
|||||||
import com.storycove.entity.CollectionStory;
|
import com.storycove.entity.CollectionStory;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
|
import com.storycove.service.AudioCoveService;
|
||||||
import com.storycove.service.CollectionService;
|
import com.storycove.service.CollectionService;
|
||||||
import com.storycove.service.EPUBExportService;
|
import com.storycove.service.EPUBExportService;
|
||||||
import com.storycove.service.ImageService;
|
import com.storycove.service.ImageService;
|
||||||
@@ -29,16 +30,19 @@ public class CollectionController {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
|
private static final Logger logger = LoggerFactory.getLogger(CollectionController.class);
|
||||||
|
|
||||||
private final CollectionService collectionService;
|
private final CollectionService collectionService;
|
||||||
|
private final AudioCoveService audioCoveService;
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
private final ReadingTimeService readingTimeService;
|
private final ReadingTimeService readingTimeService;
|
||||||
private final EPUBExportService epubExportService;
|
private final EPUBExportService epubExportService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public CollectionController(CollectionService collectionService,
|
public CollectionController(CollectionService collectionService,
|
||||||
|
AudioCoveService audioCoveService,
|
||||||
ImageService imageService,
|
ImageService imageService,
|
||||||
ReadingTimeService readingTimeService,
|
ReadingTimeService readingTimeService,
|
||||||
EPUBExportService epubExportService) {
|
EPUBExportService epubExportService) {
|
||||||
this.collectionService = collectionService;
|
this.collectionService = collectionService;
|
||||||
|
this.audioCoveService = audioCoveService;
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
this.readingTimeService = readingTimeService;
|
this.readingTimeService = readingTimeService;
|
||||||
this.epubExportService = epubExportService;
|
this.epubExportService = epubExportService;
|
||||||
@@ -281,6 +285,25 @@ public class CollectionController {
|
|||||||
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
|
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/collections/{id}/send-to-audiocove - Send combined collection content to AudioCove
|
||||||
|
*/
|
||||||
|
@PostMapping("/{id}/send-to-audiocove")
|
||||||
|
public ResponseEntity<String> sendCollectionToAudioCove(@PathVariable UUID id) {
|
||||||
|
try {
|
||||||
|
String response = audioCoveService.ingestCollection(id);
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
if (e.getMessage().contains("not configured")) {
|
||||||
|
return ResponseEntity.status(503).body(e.getMessage());
|
||||||
|
}
|
||||||
|
return ResponseEntity.badRequest().body(e.getMessage());
|
||||||
|
} catch (org.springframework.web.client.RestClientException e) {
|
||||||
|
logger.error("AudioCove gateway error for collection {}: {}", id, e.getMessage());
|
||||||
|
return ResponseEntity.status(502).body("AudioCove gateway error: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
|
* POST /api/collections/reindex-typesense - Reindex all collections in Typesense
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.storycove.controller;
|
package com.storycove.controller;
|
||||||
|
|
||||||
import com.storycove.dto.SeriesDto;
|
import com.storycove.dto.SeriesDto;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
import com.storycove.entity.Series;
|
import com.storycove.entity.Series;
|
||||||
|
import com.storycove.service.CollectionService;
|
||||||
import com.storycove.service.SeriesService;
|
import com.storycove.service.SeriesService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -22,9 +24,11 @@ import java.util.stream.Collectors;
|
|||||||
public class SeriesController {
|
public class SeriesController {
|
||||||
|
|
||||||
private final SeriesService seriesService;
|
private final SeriesService seriesService;
|
||||||
|
private final CollectionService collectionService;
|
||||||
|
|
||||||
public SeriesController(SeriesService seriesService) {
|
public SeriesController(SeriesService seriesService, CollectionService collectionService) {
|
||||||
this.seriesService = seriesService;
|
this.seriesService = seriesService;
|
||||||
|
this.collectionService = collectionService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -128,6 +132,15 @@ public class SeriesController {
|
|||||||
return ResponseEntity.ok(stats);
|
return ResponseEntity.ok(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/create-collection")
|
||||||
|
public ResponseEntity<Map<String, String>> createCollectionFromSeries(@PathVariable UUID id) {
|
||||||
|
Collection collection = collectionService.createCollectionFromSeries(id);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
|
||||||
|
"collectionId", collection.getId().toString(),
|
||||||
|
"name", collection.getName()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
private void updateSeriesFromRequest(Series series, Object request) {
|
private void updateSeriesFromRequest(Series series, Object request) {
|
||||||
if (request instanceof CreateSeriesRequest createReq) {
|
if (request instanceof CreateSeriesRequest createReq) {
|
||||||
series.setName(createReq.getName());
|
series.setName(createReq.getName());
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.storycove.service;
|
|||||||
|
|
||||||
import com.storycove.config.AudioCoveProperties;
|
import com.storycove.config.AudioCoveProperties;
|
||||||
import com.storycove.dto.AudioCoveIngestRequest;
|
import com.storycove.dto.AudioCoveIngestRequest;
|
||||||
|
import com.storycove.entity.Collection;
|
||||||
|
import com.storycove.entity.CollectionStory;
|
||||||
import com.storycove.entity.Story;
|
import com.storycove.entity.Story;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -15,6 +17,7 @@ import org.springframework.web.client.RestClientException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -24,14 +27,17 @@ public class AudioCoveService {
|
|||||||
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
|
private static final Logger logger = LoggerFactory.getLogger(AudioCoveService.class);
|
||||||
|
|
||||||
private final StoryService storyService;
|
private final StoryService storyService;
|
||||||
|
private final CollectionService collectionService;
|
||||||
private final AudioCoveProperties audioCoveProperties;
|
private final AudioCoveProperties audioCoveProperties;
|
||||||
private final String publicUrl;
|
private final String publicUrl;
|
||||||
private final RestClient restClient;
|
private final RestClient restClient;
|
||||||
|
|
||||||
public AudioCoveService(StoryService storyService,
|
public AudioCoveService(StoryService storyService,
|
||||||
|
CollectionService collectionService,
|
||||||
AudioCoveProperties audioCoveProperties,
|
AudioCoveProperties audioCoveProperties,
|
||||||
@Value("${storycove.app.public-url}") String publicUrl) {
|
@Value("${storycove.app.public-url}") String publicUrl) {
|
||||||
this.storyService = storyService;
|
this.storyService = storyService;
|
||||||
|
this.collectionService = collectionService;
|
||||||
this.audioCoveProperties = audioCoveProperties;
|
this.audioCoveProperties = audioCoveProperties;
|
||||||
this.publicUrl = publicUrl;
|
this.publicUrl = publicUrl;
|
||||||
this.restClient = RestClient.create();
|
this.restClient = RestClient.create();
|
||||||
@@ -65,6 +71,70 @@ public class AudioCoveService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String ingestCollection(UUID collectionId) {
|
||||||
|
if (!audioCoveProperties.isConfigured()) {
|
||||||
|
throw new IllegalStateException("AudioCove URL is not configured. Set the AUDIOCOVE_URL environment variable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection collection = collectionService.findById(collectionId);
|
||||||
|
List<CollectionStory> collectionStories = collection.getCollectionStories().stream()
|
||||||
|
.sorted(Comparator.comparingInt(CollectionStory::getPosition))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (collectionStories.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Collection has no stories to send to AudioCove.");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder combinedContent = new StringBuilder();
|
||||||
|
for (int i = 0; i < collectionStories.size(); i++) {
|
||||||
|
Story story = collectionStories.get(i).getStory();
|
||||||
|
String chapterTitle = story.getTitle() + " - Part " + (i + 1) + " of Collection " + collection.getName();
|
||||||
|
combinedContent.append("<h1>").append(chapterTitle).append("</h1>\n");
|
||||||
|
if (story.getContentHtml() != null) {
|
||||||
|
combinedContent.append(story.getContentHtml());
|
||||||
|
}
|
||||||
|
combinedContent.append("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
|
||||||
|
request.setStoryId("collection:" + collectionId);
|
||||||
|
request.setTitle(collection.getName());
|
||||||
|
request.setContentHtml(combinedContent.toString());
|
||||||
|
request.setContentHash(computeContentHash(combinedContent.toString()));
|
||||||
|
request.setCollection(collection.getName());
|
||||||
|
|
||||||
|
List<String> allTags = collectionStories.stream()
|
||||||
|
.flatMap(cs -> cs.getStory().getTags().stream())
|
||||||
|
.map(tag -> tag.getName())
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
request.setTags(allTags);
|
||||||
|
|
||||||
|
if (collection.getCoverImagePath() != null && !collection.getCoverImagePath().isBlank()) {
|
||||||
|
request.setCoverImage(publicUrl + "/api/files/images/" + collection.getCoverImagePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Sending collection '{}' ({}) to AudioCove with {} stories",
|
||||||
|
collection.getName(), collectionId, collectionStories.size());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String response = restClient.post()
|
||||||
|
.uri(audioCoveProperties.getUrl() + "/api/ingest")
|
||||||
|
.header("X-API-Key", audioCoveProperties.getApiKey())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
|
||||||
|
logger.info("AudioCove ingest successful for collection '{}'", collection.getName());
|
||||||
|
return response;
|
||||||
|
} catch (RestClientException e) {
|
||||||
|
logger.error("AudioCove ingest failed for collection '{}': {}", collection.getName(), e.getMessage());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private AudioCoveIngestRequest buildIngestRequest(Story story) {
|
private AudioCoveIngestRequest buildIngestRequest(Story story) {
|
||||||
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
|
AudioCoveIngestRequest request = new AudioCoveIngestRequest();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.storycove.entity.Story;
|
|||||||
import com.storycove.entity.Tag;
|
import com.storycove.entity.Tag;
|
||||||
import com.storycove.repository.CollectionRepository;
|
import com.storycove.repository.CollectionRepository;
|
||||||
import com.storycove.repository.CollectionStoryRepository;
|
import com.storycove.repository.CollectionStoryRepository;
|
||||||
|
import com.storycove.repository.SeriesRepository;
|
||||||
import com.storycove.repository.StoryRepository;
|
import com.storycove.repository.StoryRepository;
|
||||||
import com.storycove.repository.TagRepository;
|
import com.storycove.repository.TagRepository;
|
||||||
import com.storycove.service.exception.ResourceNotFoundException;
|
import com.storycove.service.exception.ResourceNotFoundException;
|
||||||
@@ -32,6 +33,7 @@ public class CollectionService {
|
|||||||
private final CollectionStoryRepository collectionStoryRepository;
|
private final CollectionStoryRepository collectionStoryRepository;
|
||||||
private final StoryRepository storyRepository;
|
private final StoryRepository storyRepository;
|
||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
private final SeriesRepository seriesRepository;
|
||||||
private final SearchServiceAdapter searchServiceAdapter;
|
private final SearchServiceAdapter searchServiceAdapter;
|
||||||
private final ReadingTimeService readingTimeService;
|
private final ReadingTimeService readingTimeService;
|
||||||
|
|
||||||
@@ -40,12 +42,14 @@ public class CollectionService {
|
|||||||
CollectionStoryRepository collectionStoryRepository,
|
CollectionStoryRepository collectionStoryRepository,
|
||||||
StoryRepository storyRepository,
|
StoryRepository storyRepository,
|
||||||
TagRepository tagRepository,
|
TagRepository tagRepository,
|
||||||
|
SeriesRepository seriesRepository,
|
||||||
SearchServiceAdapter searchServiceAdapter,
|
SearchServiceAdapter searchServiceAdapter,
|
||||||
ReadingTimeService readingTimeService) {
|
ReadingTimeService readingTimeService) {
|
||||||
this.collectionRepository = collectionRepository;
|
this.collectionRepository = collectionRepository;
|
||||||
this.collectionStoryRepository = collectionStoryRepository;
|
this.collectionStoryRepository = collectionStoryRepository;
|
||||||
this.storyRepository = storyRepository;
|
this.storyRepository = storyRepository;
|
||||||
this.tagRepository = tagRepository;
|
this.tagRepository = tagRepository;
|
||||||
|
this.seriesRepository = seriesRepository;
|
||||||
this.searchServiceAdapter = searchServiceAdapter;
|
this.searchServiceAdapter = searchServiceAdapter;
|
||||||
this.readingTimeService = readingTimeService;
|
this.readingTimeService = readingTimeService;
|
||||||
}
|
}
|
||||||
@@ -416,6 +420,26 @@ public class CollectionService {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a collection from all stories in a series, ordered by volume
|
||||||
|
*/
|
||||||
|
public Collection createCollectionFromSeries(UUID seriesId) {
|
||||||
|
com.storycove.entity.Series series = seriesRepository.findById(seriesId)
|
||||||
|
.orElseThrow(() -> new ResourceNotFoundException("Series not found with id: " + seriesId));
|
||||||
|
|
||||||
|
List<UUID> storyIds = series.getStories().stream()
|
||||||
|
.sorted(java.util.Comparator.comparingInt(s -> s.getVolume() != null ? s.getVolume() : Integer.MAX_VALUE))
|
||||||
|
.map(Story::getId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (storyIds.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Cannot create a collection from a series with no stories.");
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Creating collection from series '{}' with {} stories", series.getName(), storyIds.size());
|
||||||
|
return createCollection(series.getName(), series.getDescription(), null, storyIds);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all collections for indexing (used by SearchServiceAdapter)
|
* Get all collections for indexing (used by SearchServiceAdapter)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -279,7 +279,11 @@ public class DatabaseManagementService {
|
|||||||
Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql");
|
Path tempBackupFile = Files.createTempFile("storycove_backup_", ".sql");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Build pg_dump command
|
// Build pg_dump command.
|
||||||
|
// --no-owner / --no-acl: omit ownership and privilege SQL that would fail
|
||||||
|
// when restoring to a different environment or under a different role.
|
||||||
|
// --create is intentionally omitted: it generates ALTER DATABASE / \connect
|
||||||
|
// commands for the source database name which break cross-environment restores.
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
"pg_dump",
|
"pg_dump",
|
||||||
"--host=" + dbHost,
|
"--host=" + dbHost,
|
||||||
@@ -290,7 +294,8 @@ public class DatabaseManagementService {
|
|||||||
"--verbose",
|
"--verbose",
|
||||||
"--clean",
|
"--clean",
|
||||||
"--if-exists",
|
"--if-exists",
|
||||||
"--create",
|
"--no-owner",
|
||||||
|
"--no-acl",
|
||||||
"--file=" + tempBackupFile.toString()
|
"--file=" + tempBackupFile.toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -368,12 +373,18 @@ public class DatabaseManagementService {
|
|||||||
|
|
||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
// Skip DROP DATABASE and CREATE DATABASE commands - we're already connected to the DB
|
// Skip database-level commands that were generated by older backups using
|
||||||
// Also skip database connection commands as we're already connected
|
// pg_dump --create and that would fail when restoring to a different environment.
|
||||||
if (line.trim().startsWith("DROP DATABASE") ||
|
// New backups no longer use --create so these lines won't appear, but we keep
|
||||||
line.trim().startsWith("CREATE DATABASE") ||
|
// the filters for backward compatibility with previously created backups.
|
||||||
line.trim().startsWith("\\connect")) {
|
String trimmed = line.trim();
|
||||||
System.err.println("Skipping incompatible command: " + line.substring(0, Math.min(50, line.length())));
|
if (trimmed.startsWith("DROP DATABASE") ||
|
||||||
|
trimmed.startsWith("CREATE DATABASE") ||
|
||||||
|
trimmed.startsWith("\\connect") ||
|
||||||
|
trimmed.startsWith("\\c ") || // short-form \connect alias
|
||||||
|
trimmed.startsWith("ALTER DATABASE") ||
|
||||||
|
trimmed.startsWith("COMMENT ON DATABASE")) {
|
||||||
|
System.err.println("Skipping database-level command: " + line.substring(0, Math.min(80, line.length())));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
writer.write(line);
|
writer.write(line);
|
||||||
@@ -383,7 +394,10 @@ public class DatabaseManagementService {
|
|||||||
|
|
||||||
System.err.println("Starting PostgreSQL restore using psql...");
|
System.err.println("Starting PostgreSQL restore using psql...");
|
||||||
|
|
||||||
// Build psql command to restore the backup
|
// Build psql command to restore the backup.
|
||||||
|
// --single-transaction wraps the entire restore in one atomic transaction:
|
||||||
|
// if any statement fails (e.g. a COPY for story_tags), the whole restore rolls
|
||||||
|
// back and an error is returned rather than leaving the DB in a partial state.
|
||||||
ProcessBuilder pb = new ProcessBuilder(
|
ProcessBuilder pb = new ProcessBuilder(
|
||||||
"psql",
|
"psql",
|
||||||
"--host=" + dbHost,
|
"--host=" + dbHost,
|
||||||
@@ -392,6 +406,7 @@ public class DatabaseManagementService {
|
|||||||
"--dbname=" + dbName,
|
"--dbname=" + dbName,
|
||||||
"--no-password",
|
"--no-password",
|
||||||
"--echo-errors",
|
"--echo-errors",
|
||||||
|
"--single-transaction",
|
||||||
"--file=" + tempBackupFile.toString()
|
"--file=" + tempBackupFile.toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { storyApi, seriesApi, audioCoveApi, getImageUrl } from '../../../../lib/api';
|
import { storyApi, seriesApi, audioCoveApi, collectionApi, getImageUrl } from '../../../../lib/api';
|
||||||
import { Story, Collection } from '../../../../types/api';
|
import { Story, Collection } from '../../../../types/api';
|
||||||
import AppLayout from '../../../../components/layout/AppLayout';
|
import AppLayout from '../../../../components/layout/AppLayout';
|
||||||
import Button from '../../../../components/ui/Button';
|
import Button from '../../../../components/ui/Button';
|
||||||
@@ -26,6 +26,7 @@ export default function StoryDetailPage() {
|
|||||||
const [isExporting, setIsExporting] = useState(false);
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
|
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
|
||||||
const [audioCovedSuccess, setAudioCovedSuccess] = useState(false);
|
const [audioCovedSuccess, setAudioCovedSuccess] = useState(false);
|
||||||
|
const [isCreatingCollection, setIsCreatingCollection] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadStoryData = async () => {
|
const loadStoryData = async () => {
|
||||||
@@ -133,6 +134,20 @@ export default function StoryDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateCollectionFromSeries = async () => {
|
||||||
|
if (!story?.seriesId || isCreatingCollection) return;
|
||||||
|
setIsCreatingCollection(true);
|
||||||
|
try {
|
||||||
|
const result = await seriesApi.createCollectionFromSeries(story.seriesId);
|
||||||
|
router.push(`/collections/${result.collectionId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data || 'Failed to create collection from series.';
|
||||||
|
alert(message);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingCollection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -319,6 +334,16 @@ export default function StoryDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateCollectionFromSeries}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={isCreatingCollection}
|
||||||
|
>
|
||||||
|
{isCreatingCollection ? 'Creating...' : '📚 Create Collection from Series'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Collection } from '../../types/api';
|
import { Collection } from '../../types/api';
|
||||||
import { collectionApi, getImageUrl } from '../../lib/api';
|
import { collectionApi, audioCoveApi, getImageUrl } from '../../lib/api';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import StoryReorderList from './StoryReorderList';
|
import StoryReorderList from './StoryReorderList';
|
||||||
import AddToCollectionModal from './AddToCollectionModal';
|
import AddToCollectionModal from './AddToCollectionModal';
|
||||||
@@ -29,6 +29,8 @@ export default function CollectionDetailView({
|
|||||||
const [editRating, setEditRating] = useState(collection.rating || '');
|
const [editRating, setEditRating] = useState(collection.rating || '');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
const [isSendingToAudioCove, setIsSendingToAudioCove] = useState(false);
|
||||||
|
const [audioCoveSuccess, setAudioCoveSuccess] = useState(false);
|
||||||
|
|
||||||
const formatReadingTime = (minutes: number): string => {
|
const formatReadingTime = (minutes: number): string => {
|
||||||
if (minutes < 60) {
|
if (minutes < 60) {
|
||||||
@@ -118,6 +120,21 @@ export default function CollectionDetailView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendToAudioCove = async () => {
|
||||||
|
if (isSendingToAudioCove) return;
|
||||||
|
setIsSendingToAudioCove(true);
|
||||||
|
try {
|
||||||
|
await audioCoveApi.sendCollectionToAudioCove(collection.id);
|
||||||
|
setAudioCoveSuccess(true);
|
||||||
|
setTimeout(() => setAudioCoveSuccess(false), 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data || 'Failed to send to AudioCove. Please try again.';
|
||||||
|
alert(message);
|
||||||
|
} finally {
|
||||||
|
setIsSendingToAudioCove(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
@@ -305,6 +322,14 @@ export default function CollectionDetailView({
|
|||||||
>
|
>
|
||||||
{actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'}
|
{actionLoading === 'delete' ? <LoadingSpinner size="sm" /> : 'Delete'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleSendToAudioCove}
|
||||||
|
disabled={isSendingToAudioCove || audioCoveSuccess || collection.storyCount === 0}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isSendingToAudioCove ? '🎧 Sending...' : audioCoveSuccess ? '✅ Sent!' : '🎧 Send to Audiocove'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
|
|||||||
@@ -619,6 +619,11 @@ export const seriesApi = {
|
|||||||
const response = await api.get(`/stories/series/${id}`);
|
const response = await api.get(`/stories/series/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createCollectionFromSeries: async (seriesId: string): Promise<{ collectionId: string; name: string }> => {
|
||||||
|
const response = await api.post(`/series/${seriesId}/create-collection`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Search endpoints
|
// Search endpoints
|
||||||
@@ -1169,6 +1174,11 @@ export const audioCoveApi = {
|
|||||||
const response = await api.post(`/stories/${storyId}/send-to-audiocove`);
|
const response = await api.post(`/stories/${storyId}/send-to-audiocove`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
sendCollectionToAudioCove: async (collectionId: string): Promise<string> => {
|
||||||
|
const response = await api.post(`/collections/${collectionId}/send-to-audiocove`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Image utility - now library-aware
|
// Image utility - now library-aware
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user