mirror of
https://github.com/dev-claw/vripper-project.git
synced 2026-08-19 08:35:41 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c657cc7688 | ||
|
|
49fb659d08 | ||
|
|
71f9e208e6 | ||
|
|
7293d3d031 | ||
|
|
bf26100549 | ||
|
|
b6c7bd8e26 | ||
|
|
134abf791e | ||
|
|
1dcb07e124 | ||
|
|
b5db9f8d7b | ||
|
|
2130b30329 | ||
|
|
5f9c231eab | ||
|
|
537ad90084 | ||
|
|
81cf97cc50 | ||
|
|
d82cf55590 | ||
|
|
884704c70d | ||
|
|
e798218a21 | ||
|
|
04df3bcc81 | ||
|
|
7af0aeafa7 |
@@ -2,6 +2,58 @@
|
||||
|
||||
This is my spin for a cross-platform gallery ripper for [vipergirls.to](https://vipergirls.to).
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
## Requirements
|
||||
Direct access to vipergirls.to (or one of its [alternative domains](https://vipergirls.to/threads/5887340)), consider using a VPN otherwise.
|
||||
|
||||
|
||||
## Supported Image Hosts
|
||||
The following hosts are supported:
|
||||
|
||||
* acidimg.cc
|
||||
* imagetwist.com
|
||||
* imagezilla.com
|
||||
* imgspice.com
|
||||
* imagebam.com
|
||||
* imgbox.com
|
||||
* imx.to
|
||||
* pimpandhost.com
|
||||
* pixhost.to
|
||||
* pixxxels.cc
|
||||
* turboimagehost.com
|
||||
* postimg.cc
|
||||
* imagevenue.com
|
||||
* pixroute.to
|
||||
* vipr.im
|
||||
|
||||
|
||||
## Installation
|
||||
Download the latest version from the Release page and execute the installer depending on your Operating System
|
||||
|
||||
Application data (application logs, settings and persisted data) is stored in
|
||||
|
||||
HOME_FOLDER/vripper (Windows)
|
||||
HOME_FOLDER/.config/vripper (Linux and macOS)
|
||||
|
||||
## Instructions for Jar
|
||||
You need Java 17+, you can download from https://adoptium.net/
|
||||
|
||||
Download the latest jar file from the Release page, open a command prompt and run the jar file using the following command
|
||||
|
||||
For the GUI app
|
||||
|
||||
javaw -jar vripper-gui.jar
|
||||
|
||||
For the WEB app
|
||||
|
||||
java -jar vripper-web.jar
|
||||
|
||||
Application data (application logs, settings and persisted data) is stored in the location where you launched the jar for both GUI and WEB
|
||||
|
||||
|
||||
## How to build
|
||||
|
||||
You need JDK 17 and a recent version of maven 3.6.1+
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-project</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
<kotlin.version>1.9.20</kotlin.version>
|
||||
@@ -16,7 +16,37 @@
|
||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||
<maven.compiler.target>${java.version}</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<revision>5.2.0</revision>
|
||||
</properties>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>flatten-maven-plugin</artifactId>
|
||||
<version>1.1.0</version>
|
||||
<configuration>
|
||||
<updatePomFile>true</updatePomFile>
|
||||
<flattenMode>resolveCiFriendliesOnly</flattenMode>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>flatten</id>
|
||||
<phase>process-resources</phase>
|
||||
<goals>
|
||||
<goal>flatten</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>flatten.clean</id>
|
||||
<phase>clean</phase>
|
||||
<goals>
|
||||
<goal>clean</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
<modules>
|
||||
<module>vripper-core</module>
|
||||
<module>vripper-web-ui</module>
|
||||
@@ -28,7 +58,7 @@
|
||||
<dependency>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<version>1.4.11</version>
|
||||
<version>1.4.12</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-project</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>vripper-core</artifactId>
|
||||
|
||||
@@ -20,6 +20,7 @@ import me.vripper.utilities.formatToString
|
||||
import net.jodah.failsafe.Failsafe
|
||||
import net.jodah.failsafe.RetryPolicy
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
@@ -218,41 +219,43 @@ class DownloadService(
|
||||
|
||||
private fun scheduleForDownload(imageDownloadRunnable: ImageDownloadRunnable) {
|
||||
log.debug("Scheduling a job for ${imageDownloadRunnable.context.image.url}")
|
||||
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
|
||||
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload()).with(GLOBAL_EXECUTOR)
|
||||
.onFailure {
|
||||
try {
|
||||
dataTransaction.saveLog(
|
||||
LogEntry(
|
||||
type = LogEntry.Type.DOWNLOAD,
|
||||
status = LogEntry.Status.ERROR,
|
||||
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
|
||||
CompletableFuture.runAsync({
|
||||
eventBus.publishEvent(QueueStateEvent(QueueState(runningCount(), pendingCount())))
|
||||
Failsafe.with<Any, RetryPolicy<Any>>(retryPolicyService.buildRetryPolicyForDownload())
|
||||
.onFailure {
|
||||
try {
|
||||
dataTransaction.saveLog(
|
||||
LogEntry(
|
||||
type = LogEntry.Type.DOWNLOAD,
|
||||
status = LogEntry.Status.ERROR,
|
||||
message = "Failed to download ${imageDownloadRunnable.context.image.url}\n ${it.failure.formatToString()}"
|
||||
)
|
||||
)
|
||||
} catch (exp: Exception) {
|
||||
log.error("Failed to save event", exp)
|
||||
}
|
||||
log.error(
|
||||
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
|
||||
it.failure
|
||||
)
|
||||
val image = imageDownloadRunnable.context.image
|
||||
image.status = Status.ERROR
|
||||
dataTransaction.updateImage(image)
|
||||
}.onComplete {
|
||||
afterJobFinish(imageDownloadRunnable)
|
||||
eventBus.publishEvent(
|
||||
QueueStateEvent(
|
||||
QueueState(
|
||||
runningCount(), pendingCount()
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (exp: Exception) {
|
||||
log.error("Failed to save event", exp)
|
||||
}
|
||||
log.error(
|
||||
"Failed to download ${imageDownloadRunnable.context.image.url} after ${it.attemptCount} tries",
|
||||
it.failure
|
||||
)
|
||||
val image = imageDownloadRunnable.context.image
|
||||
image.status = Status.ERROR
|
||||
dataTransaction.updateImage(image)
|
||||
}.onComplete {
|
||||
afterJobFinish(imageDownloadRunnable)
|
||||
eventBus.publishEvent(
|
||||
QueueStateEvent(
|
||||
QueueState(
|
||||
runningCount(), pendingCount()
|
||||
)
|
||||
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
|
||||
log.debug(
|
||||
"Finished downloading ${imageDownloadRunnable.context.image.url}"
|
||||
)
|
||||
)
|
||||
eventBus.publishEvent(ErrorCountEvent(ErrorCount(dataTransaction.countImagesInError())))
|
||||
log.debug(
|
||||
"Finished downloading ${imageDownloadRunnable.context.image.url}"
|
||||
)
|
||||
}.runAsync(imageDownloadRunnable::run)
|
||||
}.run(imageDownloadRunnable::run)
|
||||
}, GLOBAL_EXECUTOR)
|
||||
}
|
||||
|
||||
private fun afterJobFinish(imageDownloadRunnable: ImageDownloadRunnable) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.util.*
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
class ImageDownloadRunnable(
|
||||
@@ -43,10 +44,8 @@ class ImageDownloadRunnable(
|
||||
image.status = Status.DOWNLOADING
|
||||
image.downloaded = 0
|
||||
dataTransaction.updateImage(image)
|
||||
val downloadDirectory: String
|
||||
synchronized(image.postId.toString().intern()) {
|
||||
val post = dataTransaction.findPostById(context.postId).orElseThrow()
|
||||
downloadDirectory = post.downloadDirectory
|
||||
if (post.status != Status.DOWNLOADING) {
|
||||
post.status = Status.DOWNLOADING
|
||||
dataTransaction.updatePost(post)
|
||||
@@ -61,13 +60,14 @@ class ImageDownloadRunnable(
|
||||
log.debug(
|
||||
"Sanitizing image name from ${downloadedImage.name} to $sanitizedFileName"
|
||||
)
|
||||
checkImageTypeAndRename(
|
||||
downloadDirectory, downloadedImage, image.index
|
||||
)
|
||||
synchronized(image.postId.toString().intern()) {
|
||||
val post = dataTransaction.findPostById(context.postId).orElseThrow()
|
||||
val downloadDirectory = Path(post.downloadDirectory, post.folderName).pathString
|
||||
checkImageTypeAndRename(
|
||||
downloadDirectory, downloadedImage, image.index
|
||||
)
|
||||
if (image.downloaded == image.size && image.size > 0) {
|
||||
image.status = Status.FINISHED
|
||||
val post = dataTransaction.findPostById(context.postId).orElseThrow()
|
||||
post.done += 1
|
||||
post.downloaded += image.size
|
||||
dataTransaction.updatePost(post)
|
||||
@@ -102,9 +102,7 @@ class ImageDownloadRunnable(
|
||||
if (existingExtension.isBlank()) "${downloadedImage.name}.$extension" else downloadedImage.name
|
||||
try {
|
||||
val downloadDestinationFolder = Path.of(downloadDirectory)
|
||||
synchronized(downloadDestinationFolder.pathString.intern()) {
|
||||
Files.createDirectories(downloadDestinationFolder)
|
||||
}
|
||||
Files.createDirectories(downloadDestinationFolder)
|
||||
val image = downloadDestinationFolder.resolve(
|
||||
"${
|
||||
if (settings.downloadSettings.forceOrder) String.format(
|
||||
|
||||
@@ -3,7 +3,7 @@ package me.vripper.entities
|
||||
import me.vripper.entities.domain.Status
|
||||
|
||||
data class Image(
|
||||
var id: Long = -1,
|
||||
val id: Long = -1,
|
||||
val postId: Long,
|
||||
val url: String,
|
||||
val thumbUrl: String,
|
||||
|
||||
@@ -3,9 +3,11 @@ package me.vripper.entities
|
||||
import com.fasterxml.jackson.annotation.JsonFormat
|
||||
import me.vripper.entities.domain.Status
|
||||
import java.time.LocalDateTime
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
data class Post(
|
||||
var id: Long = -1,
|
||||
val id: Long = -1,
|
||||
val postTitle: String,
|
||||
val threadTitle: String,
|
||||
val forum: String,
|
||||
@@ -17,12 +19,18 @@ data class Post(
|
||||
val hosts: Set<String>,
|
||||
val downloadDirectory: String,
|
||||
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss") val addedOn: LocalDateTime = LocalDateTime.now(),
|
||||
var folderName: String,
|
||||
var status: Status = Status.STOPPED,
|
||||
var done: Int = 0,
|
||||
var rank: Int = Int.MAX_VALUE,
|
||||
var size: Long = -1,
|
||||
var downloaded: Long = 0,
|
||||
) {
|
||||
|
||||
fun getDownloadFolder(): String {
|
||||
return Path(downloadDirectory, folderName).pathString
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
@@ -9,12 +9,9 @@ import me.vripper.model.ErrorCount
|
||||
import me.vripper.model.QueueState
|
||||
import me.vripper.model.Settings
|
||||
|
||||
data class PostEvent(
|
||||
val add: List<Post> = emptyList(),
|
||||
val update: List<Post> = emptyList(),
|
||||
val delete: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
data class PostCreateEvent(val posts: List<Post>)
|
||||
data class PostUpdateEvent(val posts: List<Post>)
|
||||
data class PostDeleteEvent(val postIds: List<Long>)
|
||||
data class ImageEvent(val images: List<Image>)
|
||||
data class ThreadCreateEvent(val thread: Thread)
|
||||
data class ThreadDeleteEvent(val threadId: Long)
|
||||
|
||||
@@ -8,10 +8,10 @@ import java.util.concurrent.Executors
|
||||
|
||||
object EventBus {
|
||||
|
||||
private val Scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
|
||||
private val scheduler: Scheduler = Schedulers.fromExecutor(Executors.newSingleThreadExecutor())
|
||||
private val _events = Sinks.many().multicast().onBackpressureBuffer<Any>()
|
||||
val events: Flux<Any> = _events.asFlux().publishOn(Scheduler)
|
||||
val events: Flux<Any> = _events.asFlux().publishOn(scheduler)
|
||||
fun publishEvent(event: Any) {
|
||||
_events.tryEmitNext(event)
|
||||
_events.emitNext(event) { _, _ -> true }
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -23,6 +23,7 @@ class PostDownloadStateRepositoryImpl :
|
||||
this[PostTable.rank] = post.rank
|
||||
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
|
||||
this[PostTable.outputPath] = post.downloadDirectory
|
||||
this[PostTable.folderName] = post.folderName
|
||||
this[PostTable.postId] = post.postId
|
||||
this[PostTable.threadId] = post.threadId
|
||||
this[PostTable.postTitle] = post.postTitle
|
||||
@@ -90,6 +91,7 @@ class PostDownloadStateRepositoryImpl :
|
||||
it[rank] = post.rank
|
||||
it[size] = post.size
|
||||
it[downloaded] = post.downloaded
|
||||
it[folderName] = post.folderName
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +104,7 @@ class PostDownloadStateRepositoryImpl :
|
||||
this[PostTable.rank] = post.rank
|
||||
this[PostTable.hosts] = post.hosts.joinToString(delimiter)
|
||||
this[PostTable.outputPath] = post.downloadDirectory
|
||||
this[PostTable.folderName] = post.folderName
|
||||
this[PostTable.postId] = post.postId
|
||||
this[PostTable.threadId] = post.threadId
|
||||
this[PostTable.postTitle] = post.postTitle
|
||||
@@ -171,6 +174,7 @@ class PostDownloadStateRepositoryImpl :
|
||||
val hosts =
|
||||
resultRow[PostTable.hosts].split(delimiter).dropLastWhile { it.isEmpty() }.toSet()
|
||||
val downloadDirectory = resultRow[PostTable.outputPath]
|
||||
val folderName = resultRow[PostTable.folderName]
|
||||
val addedOn = resultRow[PostTable.addedAt]
|
||||
val rank = resultRow[PostTable.rank]
|
||||
val size = resultRow[PostTable.size]
|
||||
@@ -188,6 +192,7 @@ class PostDownloadStateRepositoryImpl :
|
||||
hosts,
|
||||
downloadDirectory,
|
||||
addedOn,
|
||||
folderName,
|
||||
status,
|
||||
done,
|
||||
rank,
|
||||
|
||||
@@ -8,9 +8,12 @@ import me.vripper.services.*
|
||||
import me.vripper.tasks.AddPostRunnable
|
||||
import me.vripper.tasks.ThreadLookupRunnable
|
||||
import me.vripper.utilities.GLOBAL_EXECUTOR
|
||||
import me.vripper.utilities.PathUtils
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.io.path.Path
|
||||
import kotlin.io.path.exists
|
||||
|
||||
class AppEndpointService(
|
||||
private val downloadService: DownloadService,
|
||||
@@ -65,7 +68,7 @@ class AppEndpointService(
|
||||
|
||||
@Synchronized
|
||||
fun restartAll(posIds: List<Long> = listOf()) {
|
||||
downloadService.restartAll(posIds.map { dataTransaction.findPostsByPostId(it) }.filter { it.isPresent }
|
||||
downloadService.restartAll(posIds.map { dataTransaction.findPostByPostId(it) }.filter { it.isPresent }
|
||||
.map { it.get() })
|
||||
}
|
||||
|
||||
@@ -139,6 +142,19 @@ class AppEndpointService(
|
||||
|
||||
fun logClear() {
|
||||
dataTransaction.deleteAllLogs()
|
||||
}
|
||||
|
||||
fun rename(postId: Long, name: String) {
|
||||
CompletableFuture.runAsync({
|
||||
synchronized(postId.toString().intern()) {
|
||||
dataTransaction.findPostByPostId(postId).ifPresent { post ->
|
||||
if (Path(post.downloadDirectory, post.folderName).exists()) {
|
||||
PathUtils.rename(post.downloadDirectory, post.folderName, name)
|
||||
}
|
||||
post.folderName = name
|
||||
dataTransaction.updatePost(post)
|
||||
}
|
||||
}
|
||||
}, GLOBAL_EXECUTOR)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import me.vripper.model.ErrorCount
|
||||
import me.vripper.model.PostItem
|
||||
import me.vripper.repositories.*
|
||||
import me.vripper.utilities.PathUtils
|
||||
import me.vripper.utilities.PathUtils.sanitize
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import java.util.*
|
||||
import kotlin.io.path.pathString
|
||||
@@ -27,12 +28,12 @@ class DataTransaction(
|
||||
|
||||
fun updatePosts(posts: List<Post>) {
|
||||
transaction { postDownloadStateRepository.update(posts) }
|
||||
eventBus.publishEvent(PostEvent(update = posts))
|
||||
eventBus.publishEvent(PostUpdateEvent(posts))
|
||||
}
|
||||
|
||||
fun updatePost(posts: Post) {
|
||||
transaction { postDownloadStateRepository.update(posts) }
|
||||
eventBus.publishEvent(PostEvent(update = listOf(posts)))
|
||||
fun updatePost(post: Post) {
|
||||
transaction { postDownloadStateRepository.update(post) }
|
||||
eventBus.publishEvent(PostUpdateEvent(listOf(post)))
|
||||
}
|
||||
|
||||
fun save(thread: Thread) {
|
||||
@@ -75,10 +76,11 @@ class DataTransaction(
|
||||
downloadDirectory = PathUtils.calculateDownloadPath(
|
||||
postItem.forum,
|
||||
postItem.threadTitle,
|
||||
postItem.title,
|
||||
postItem.postId,
|
||||
settingsService.settings
|
||||
).pathString
|
||||
).pathString,
|
||||
folderName = if (settingsService.settings.downloadSettings.appendPostId) "${sanitize(postItem.title)}_${postItem.postId}" else sanitize(
|
||||
postItem.title
|
||||
)
|
||||
)
|
||||
val images = postItem.imageItemList.mapIndexed { index, imageItem ->
|
||||
Image(
|
||||
@@ -102,7 +104,7 @@ class DataTransaction(
|
||||
}
|
||||
savedPosts
|
||||
}
|
||||
eventBus.publishEvent(PostEvent(add = savedPosts))
|
||||
eventBus.publishEvent(PostCreateEvent(savedPosts))
|
||||
return savedPosts
|
||||
}
|
||||
|
||||
@@ -115,7 +117,7 @@ class DataTransaction(
|
||||
}
|
||||
|
||||
fun finishPost(postId: Long, automatic: Boolean = false) {
|
||||
val post = findPostsByPostId(postId).orElseThrow()
|
||||
val post = findPostByPostId(postId).orElseThrow()
|
||||
val imagesInErrorStatus = findByPostIdAndIsError(post.postId)
|
||||
if (imagesInErrorStatus.isNotEmpty()) {
|
||||
post.status = Status.ERROR
|
||||
@@ -150,7 +152,7 @@ class DataTransaction(
|
||||
}
|
||||
|
||||
|
||||
eventBus.publishEvent(PostEvent(delete = postIds))
|
||||
eventBus.publishEvent(PostDeleteEvent(postIds = postIds))
|
||||
eventBus.publishEvent(ErrorCountEvent(ErrorCount(countImagesInError())))
|
||||
}
|
||||
|
||||
@@ -239,7 +241,7 @@ class DataTransaction(
|
||||
return transaction { imageRepository.countError() }
|
||||
}
|
||||
|
||||
fun findPostsByPostId(postId: Long): Optional<Post> {
|
||||
fun findPostByPostId(postId: Long): Optional<Post> {
|
||||
return transaction { postDownloadStateRepository.findByPostId(postId) }
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ object PostTable : LongIdTable(name = "POST", columnName = "ID") {
|
||||
val done = integer("DONE")
|
||||
val hosts = varchar("HOSTS", 255)
|
||||
val outputPath = varchar("OUTPUT_PATH", 260)
|
||||
val folderName = varchar("FOLDER_NAME", 260)
|
||||
val postId = long("POST_ID")
|
||||
val status = varchar("STATUS", 15)
|
||||
val threadId = long("THREAD_ID")
|
||||
|
||||
@@ -6,7 +6,7 @@ import kotlin.io.path.Path
|
||||
import kotlin.io.path.pathString
|
||||
|
||||
object ApplicationProperties {
|
||||
const val VERSION: String = "5.0.0"
|
||||
const val VERSION: String = "5.2.0"
|
||||
private const val BASE_DIR_NAME: String = "vripper"
|
||||
private val portable = System.getProperty("vripper.portable", "true").toBoolean()
|
||||
private val BASE_DIR: String = getBaseDir()
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package me.vripper.utilities
|
||||
|
||||
import me.vripper.exception.RenameException
|
||||
import me.vripper.model.Settings
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
import kotlin.io.path.Path
|
||||
|
||||
|
||||
object PathUtils {
|
||||
private val log by me.vripper.delegate.LoggerDelegate()
|
||||
@@ -9,7 +15,7 @@ object PathUtils {
|
||||
|
||||
|
||||
fun calculateDownloadPath(
|
||||
forum: String, threadTitle: String, postTitle: String, postId: Long, settings: Settings
|
||||
forum: String, threadTitle: String, settings: Settings
|
||||
): Path {
|
||||
var downloadDirectory = if (settings.downloadSettings.forumSubDirectory) Path.of(
|
||||
settings.downloadSettings.downloadPath, sanitize(forum)
|
||||
@@ -18,14 +24,29 @@ object PathUtils {
|
||||
)
|
||||
downloadDirectory =
|
||||
if (settings.downloadSettings.threadSubLocation) downloadDirectory.resolve(threadTitle) else downloadDirectory
|
||||
downloadDirectory = downloadDirectory.resolve(
|
||||
if (settings.downloadSettings.appendPostId) "${sanitize(postTitle)}_${postId}" else sanitize(
|
||||
postTitle
|
||||
)
|
||||
)
|
||||
return downloadDirectory
|
||||
}
|
||||
|
||||
@Throws(RenameException::class)
|
||||
fun rename(downloadDirectory: String, oldFolder: String, newFolder: String) {
|
||||
val currentDownloadDirectory = Path(downloadDirectory, oldFolder)
|
||||
val newDownloadDirectory = Path(downloadDirectory, sanitize(newFolder))
|
||||
try {
|
||||
Files.move(
|
||||
currentDownloadDirectory,
|
||||
newDownloadDirectory,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
throw RenameException(
|
||||
String.format(
|
||||
"Failed to move files from %s to %s", currentDownloadDirectory, newDownloadDirectory
|
||||
),
|
||||
e
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Will sanitize the image name and remove extension
|
||||
*
|
||||
|
||||
@@ -145,4 +145,9 @@
|
||||
<column name="THREAD_ID"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
<changeSet id="0006" author="vripper">
|
||||
<addColumn tableName="POST">
|
||||
<column name="FOLDER_NAME" type="VARCHAR(260)" afterColumn="OUTPUT_PATH" defaultValue=""/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<artifactId>vripper-project</artifactId>
|
||||
<groupId>me.vripper</groupId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<dependency>
|
||||
<artifactId>vripper-core</artifactId>
|
||||
<groupId>me.vripper</groupId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package me.vripper.gui.controller
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import me.vripper.entities.Image
|
||||
import me.vripper.entities.Post
|
||||
import me.vripper.gui.model.PostModel
|
||||
import me.vripper.services.AppEndpointService
|
||||
import me.vripper.services.DataTransaction
|
||||
@@ -58,25 +57,26 @@ class PostController : Controller() {
|
||||
}
|
||||
|
||||
fun findAllPosts(): Deferred<List<PostModel>> {
|
||||
return coroutineScope.async { dataTransaction.findAllPosts().map(::mapper) }
|
||||
return coroutineScope.async { dataTransaction.findAllPosts().map { it.id }.map(::mapper) }
|
||||
}
|
||||
|
||||
fun mapper(it: Post): PostModel {
|
||||
val updated = dataTransaction.findPostById(it.id).orElseThrow()
|
||||
fun mapper(id: Long): PostModel {
|
||||
val post = dataTransaction.findPostById(id).orElseThrow()
|
||||
return PostModel(
|
||||
updated.postId,
|
||||
updated.postTitle,
|
||||
progress(updated.total, updated.done),
|
||||
updated.status.name,
|
||||
updated.url,
|
||||
updated.done,
|
||||
updated.total,
|
||||
updated.hosts.joinToString(separator = ", "),
|
||||
updated.addedOn.format(dateTimeFormatter),
|
||||
updated.rank + 1,
|
||||
updated.downloadDirectory,
|
||||
progressCount(updated.total, updated.done, updated.downloaded),
|
||||
dataTransaction.findImagesByPostId(updated.postId).map(Image::thumbUrl).take(4)
|
||||
post.postId,
|
||||
post.postTitle,
|
||||
progress(post.total, post.done),
|
||||
post.status.name,
|
||||
post.url,
|
||||
post.done,
|
||||
post.total,
|
||||
post.hosts.joinToString(separator = ", "),
|
||||
post.addedOn.format(dateTimeFormatter),
|
||||
post.rank + 1,
|
||||
post.getDownloadFolder(),
|
||||
post.folderName,
|
||||
progressCount(post.total, post.done, post.downloaded),
|
||||
dataTransaction.findImagesByPostId(post.postId).map(Image::thumbUrl).take(4)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,4 +87,8 @@ class PostController : Controller() {
|
||||
fun progress(total: Int, done: Int): Double {
|
||||
return if (done == 0 && total == 0) 0.0 else (done.toDouble() / total)
|
||||
}
|
||||
|
||||
fun rename(postId: Long, value: String) {
|
||||
appEndpointService.rename(postId, value)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class PostModel(
|
||||
addedOn: String,
|
||||
order: Int,
|
||||
path: String,
|
||||
folderName: String,
|
||||
progressCount: String,
|
||||
previewList: List<String>
|
||||
) {
|
||||
@@ -55,6 +56,9 @@ class PostModel(
|
||||
val pathProperty = SimpleStringProperty(path)
|
||||
var path: String by pathProperty
|
||||
|
||||
val folderNameProperty = SimpleStringProperty(folderName)
|
||||
var folderName: String by folderNameProperty
|
||||
|
||||
val progressCountProperty = SimpleStringProperty(progressCount)
|
||||
var progressCount: String by progressCountProperty
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package me.vripper.gui.view
|
||||
|
||||
import javafx.event.EventHandler
|
||||
import javafx.scene.control.ProgressIndicator
|
||||
import javafx.scene.image.Image
|
||||
import javafx.scene.image.ImageView
|
||||
@@ -7,13 +8,15 @@ import javafx.scene.layout.HBox
|
||||
import javafx.stage.Popup
|
||||
import javafx.stage.Stage
|
||||
import kotlinx.coroutines.*
|
||||
import me.vripper.delegate.LoggerDelegate
|
||||
import me.vripper.gui.view.PreviewCache.previewDispatcher
|
||||
import tornadofx.add
|
||||
import tornadofx.runLater
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class Preview(private val owner: Stage, val images: List<String>) {
|
||||
class Preview(private val owner: Stage, private val images: List<String>) {
|
||||
|
||||
private val log by LoggerDelegate()
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var previewLoadJob: Job? = null
|
||||
val previewPopup = Popup()
|
||||
@@ -43,6 +46,9 @@ class Preview(private val owner: Stage, val images: List<String>) {
|
||||
yield()
|
||||
runLater {
|
||||
val hBox = HBox()
|
||||
hBox.onMouseEntered = EventHandler {
|
||||
hide()
|
||||
}
|
||||
imageViewList.forEach { hBox.add(it) }
|
||||
previewPopup.content.clear()
|
||||
previewPopup.content.add(hBox)
|
||||
@@ -54,16 +60,19 @@ class Preview(private val owner: Stage, val images: List<String>) {
|
||||
private fun previewLoading(url: String): Deferred<ImageView?> {
|
||||
return coroutineScope.async(previewDispatcher) {
|
||||
val imageView = try {
|
||||
ImageView(Image(ByteArrayInputStream(PreviewCache.cache[url]))).apply {
|
||||
isPreserveRatio = true
|
||||
ByteArrayInputStream(PreviewCache.cache[url]).use {
|
||||
ImageView(Image(it)).apply {
|
||||
isPreserveRatio = true
|
||||
|
||||
fitWidth = if (image.width > 200.0) {
|
||||
if (image.width > image.height) 200.0 * image.width / image.height else 200.0
|
||||
} else {
|
||||
200.0
|
||||
fitWidth = if (image.width > 200.0) {
|
||||
if (image.width > image.height) 200.0 * image.width / image.height else 200.0
|
||||
} else {
|
||||
200.0
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn("Failed to load preview $url")
|
||||
null
|
||||
}
|
||||
imageView
|
||||
|
||||
@@ -28,7 +28,7 @@ object PreviewCache : KoinComponent {
|
||||
.maximumWeight(1024 * 1024 * 100)
|
||||
.build(::load)
|
||||
|
||||
fun load(url: String): ByteArray {
|
||||
private fun load(url: String): ByteArray {
|
||||
val path = cachePath.resolve(url.hash256())
|
||||
if (Files.exists(path) && Files.isRegularFile(path)) {
|
||||
return Files.readAllBytes(path)
|
||||
|
||||
@@ -98,6 +98,25 @@ class DownloadActionsView : View() {
|
||||
postsTableView.stopSelected()
|
||||
}
|
||||
}
|
||||
button("Rename") {
|
||||
setPrefSize(32.0, 32.0)
|
||||
imageview("edit.png") {
|
||||
fitWidth = 32.0
|
||||
fitHeight = 32.0
|
||||
}
|
||||
addClass(Styles.actionBarButton)
|
||||
contentDisplay = ContentDisplay.GRAPHIC_ONLY
|
||||
tooltip("Rename selected [Ctrl+R]")
|
||||
shortcut(KeyCodeCombination(KeyCode.R, KeyCombination.CONTROL_DOWN))
|
||||
enableWhen(
|
||||
postsTableView.tableView.selectionModel.selectedItems.sizeProperty.greaterThan(
|
||||
0
|
||||
)
|
||||
)
|
||||
action {
|
||||
postsTableView.renameSelected()
|
||||
}
|
||||
}
|
||||
button("Delete") {
|
||||
setPrefSize(32.0, 32.0)
|
||||
imageview("trash.png") {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package me.vripper.gui.view.popup
|
||||
|
||||
import javafx.beans.property.SimpleStringProperty
|
||||
import javafx.geometry.Pos
|
||||
import javafx.scene.control.TextField
|
||||
import javafx.scene.layout.Priority
|
||||
import javafx.scene.layout.VBox
|
||||
import me.vripper.gui.controller.PostController
|
||||
import tornadofx.*
|
||||
|
||||
class RenameView : Fragment("Rename download post") {
|
||||
|
||||
val postId: Long by param()
|
||||
val name: String by param()
|
||||
private val textAreaProperty = SimpleStringProperty()
|
||||
private val postController: PostController by inject()
|
||||
lateinit var input: TextField
|
||||
|
||||
override fun onDock() {
|
||||
textAreaProperty.value = name
|
||||
}
|
||||
|
||||
override val root = vbox(alignment = Pos.CENTER_RIGHT) {
|
||||
padding = insets(all = 5)
|
||||
spacing = 5.0
|
||||
input = textfield {
|
||||
VBox.setVgrow(this, Priority.ALWAYS)
|
||||
bind(textAreaProperty)
|
||||
}
|
||||
button("Ok") {
|
||||
imageview("search.png") {
|
||||
fitWidth = 18.0
|
||||
fitHeight = 18.0
|
||||
}
|
||||
disableWhen(textAreaProperty.isEmpty)
|
||||
action {
|
||||
postController.rename(postId, textAreaProperty.value)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class ImagesTableView : Fragment("Photos") {
|
||||
it.copy(images = it.images.filter { image: Image -> image.postId == postId })
|
||||
}.filter {
|
||||
it.images.isNotEmpty()
|
||||
}.buffer(Duration.ofMillis(125))
|
||||
}.buffer(Duration.ofMillis(175))
|
||||
.subscribe { imageEvent ->
|
||||
runLater {
|
||||
for (image in imageEvent.map { it.images }.flatten().reversed().distinct()) {
|
||||
|
||||
@@ -14,13 +14,15 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import me.vripper.event.EventBus
|
||||
import me.vripper.event.PostEvent
|
||||
import me.vripper.event.PostCreateEvent
|
||||
import me.vripper.event.PostDeleteEvent
|
||||
import me.vripper.event.PostUpdateEvent
|
||||
import me.vripper.gui.clipboard.ClipboardService
|
||||
import me.vripper.gui.controller.PostController
|
||||
import me.vripper.gui.model.PostModel
|
||||
import me.vripper.gui.view.*
|
||||
import me.vripper.gui.view.popup.RenameView
|
||||
import tornadofx.*
|
||||
import java.time.Duration
|
||||
|
||||
class PostsTableView : View() {
|
||||
|
||||
@@ -54,15 +56,17 @@ class PostsTableView : View() {
|
||||
clipboardService.init()
|
||||
}
|
||||
}
|
||||
eventBus.events.ofType(PostEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { postEvents ->
|
||||
runLater {
|
||||
postEvents.map { it.delete }.flatten().forEach {
|
||||
items.removeIf { p -> p.postId == it }
|
||||
}
|
||||
val add = postEvents.map { it.add }.flatten().map { postController.mapper(it) }
|
||||
tableView.items.addAll(add)
|
||||
|
||||
for (post in postEvents.map { it.update }.flatten().reversed().distinct()) {
|
||||
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { postEvents ->
|
||||
val add = postEvents.posts.map { postController.mapper(it.id) }
|
||||
runLater {
|
||||
tableView.items.addAll(add)
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { postEvents ->
|
||||
runLater {
|
||||
for (post in postEvents.posts) {
|
||||
val postModel = items.find { it.postId == post.postId } ?: continue
|
||||
|
||||
postModel.status = post.status.name
|
||||
@@ -74,6 +78,16 @@ class PostsTableView : View() {
|
||||
postModel.progress = postController.progress(
|
||||
post.total, post.done
|
||||
)
|
||||
postModel.path = post.getDownloadFolder()
|
||||
postModel.folderName = post.folderName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { postEvents ->
|
||||
runLater {
|
||||
postEvents.postIds.forEach {
|
||||
items.removeIf { p -> p.postId == it }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +124,16 @@ class PostsTableView : View() {
|
||||
}
|
||||
}
|
||||
|
||||
val renameItem = MenuItem("Rename").apply {
|
||||
setOnAction {
|
||||
rename(tableRow.item)
|
||||
}
|
||||
graphic = ImageView("edit.png").apply {
|
||||
fitWidth = 18.0
|
||||
fitHeight = 18.0
|
||||
}
|
||||
}
|
||||
|
||||
val deleteItem = MenuItem("Delete").apply {
|
||||
setOnAction {
|
||||
deleteSelected()
|
||||
@@ -152,7 +176,7 @@ class PostsTableView : View() {
|
||||
|
||||
val contextMenu = ContextMenu()
|
||||
contextMenu.items.addAll(
|
||||
startItem, stopItem, deleteItem, SeparatorMenuItem(), detailsItem, locationItem, urlItem
|
||||
startItem, stopItem, renameItem, deleteItem, SeparatorMenuItem(), detailsItem, locationItem, urlItem
|
||||
)
|
||||
tableRow.contextMenuProperty()
|
||||
.bind(tableRow.emptyProperty().map { empty -> if (empty) null else contextMenu })
|
||||
@@ -163,23 +187,24 @@ class PostsTableView : View() {
|
||||
cellFactory = Callback {
|
||||
val cell = PreviewTableCell<PostModel>()
|
||||
cell.onMouseEntered = EventHandler { mouseEvent ->
|
||||
preview?.hide()
|
||||
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
|
||||
preview = Preview(currentStage!!, cell.tableRow.item.previewList)
|
||||
preview?.previewPopup?.apply {
|
||||
x = mouseEvent.screenX + 20
|
||||
y = mouseEvent.screenY + 10
|
||||
}
|
||||
cell.onMouseMoved = EventHandler {
|
||||
preview?.previewPopup?.apply {
|
||||
x = it.screenX + 20
|
||||
y = it.screenY + 10
|
||||
}
|
||||
}
|
||||
cell.onMouseExited = EventHandler {
|
||||
preview?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
cell.onMouseMoved = EventHandler {
|
||||
preview?.previewPopup?.apply {
|
||||
x = it.screenX + 20
|
||||
y = it.screenY + 10
|
||||
}
|
||||
}
|
||||
cell.onMouseExited = EventHandler {
|
||||
preview?.hide()
|
||||
}
|
||||
cell
|
||||
}
|
||||
}
|
||||
@@ -272,6 +297,20 @@ class PostsTableView : View() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun rename(post: PostModel) {
|
||||
find<RenameView>(mapOf(RenameView::postId to post.postId, RenameView::name to post.folderName)).openModal()
|
||||
?.apply {
|
||||
minWidth = 300.0
|
||||
}
|
||||
}
|
||||
|
||||
fun renameSelected() {
|
||||
val selectedItem = tableView.selectionModel.selectedItem
|
||||
if (selectedItem != null) {
|
||||
rename(selectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSelected() {
|
||||
val postIdList = tableView.selectionModel.selectedItems.map { it.postId }
|
||||
confirm(
|
||||
|
||||
@@ -84,23 +84,24 @@ class ThreadSelectionTableView : Fragment("Thread") {
|
||||
val cell = PreviewTableCell<ThreadSelectionModel>()
|
||||
cell.alignment = Pos.CENTER
|
||||
cell.onMouseEntered = EventHandler { mouseEvent ->
|
||||
preview?.hide()
|
||||
if (cell.tableRow.item != null && cell.tableRow.item.previewList.isNotEmpty()) {
|
||||
preview = Preview(currentStage!!, cell.tableRow.item.previewList)
|
||||
preview?.previewPopup?.apply {
|
||||
x = mouseEvent.screenX + 20
|
||||
y = mouseEvent.screenY + 10
|
||||
}
|
||||
cell.onMouseMoved = EventHandler {
|
||||
preview?.previewPopup?.apply {
|
||||
x = it.screenX + 20
|
||||
y = it.screenY + 10
|
||||
}
|
||||
}
|
||||
cell.onMouseExited = EventHandler {
|
||||
preview?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
cell.onMouseMoved = EventHandler {
|
||||
preview?.previewPopup?.apply {
|
||||
x = it.screenX + 20
|
||||
y = it.screenY + 10
|
||||
}
|
||||
}
|
||||
cell.onMouseExited = EventHandler {
|
||||
preview?.hide()
|
||||
}
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 632 B |
@@ -30,7 +30,6 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
@@ -61,10 +60,10 @@
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"browserTarget": "vripper-web-ui:build:production"
|
||||
"buildTarget": "vripper-web-ui:build:production"
|
||||
},
|
||||
"development": {
|
||||
"browserTarget": "vripper-web-ui:build:development"
|
||||
"buildTarget": "vripper-web-ui:build:development"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -75,7 +74,7 @@
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"browserTarget": "vripper-web-ui:build"
|
||||
"buildTarget": "vripper-web-ui:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
@@ -92,7 +91,6 @@
|
||||
"src/assets"
|
||||
],
|
||||
"styles": [
|
||||
"@angular/material/prebuilt-themes/indigo-pink.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
|
||||
Generated
-15784
File diff suppressed because it is too large
Load Diff
+23
-25
@@ -11,37 +11,35 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.0.0",
|
||||
"@angular/cdk": "^16.0.0",
|
||||
"@angular/common": "^16.0.0",
|
||||
"@angular/compiler": "^16.0.0",
|
||||
"@angular/core": "^16.0.0",
|
||||
"@angular/forms": "^16.0.0",
|
||||
"@angular/material": "^16.0.0",
|
||||
"@angular/platform-browser": "^16.0.0",
|
||||
"@angular/platform-browser-dynamic": "^16.0.0",
|
||||
"@angular/router": "^16.0.0",
|
||||
"@angular/animations": "^17.0.5",
|
||||
"@angular/cdk": "^17.0.1",
|
||||
"@angular/common": "^17.0.5",
|
||||
"@angular/compiler": "^17.0.5",
|
||||
"@angular/core": "^17.0.5",
|
||||
"@angular/forms": "^17.0.5",
|
||||
"@angular/material": "^17.0.1",
|
||||
"@angular/platform-browser": "^17.0.5",
|
||||
"@angular/platform-browser-dynamic": "^17.0.5",
|
||||
"@angular/router": "^17.0.5",
|
||||
"@stomp/rx-stomp": "^2.0.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"ag-grid-angular": "^29.3.4",
|
||||
"ag-grid-community": "^29.3.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.13.0"
|
||||
"zone.js": "~0.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.0.0",
|
||||
"@angular-eslint/builder": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin": "16.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "16.0.1",
|
||||
"@angular-eslint/schematics": "16.0.1",
|
||||
"@angular-eslint/template-parser": "16.0.1",
|
||||
"@angular/cli": "~16.0.0",
|
||||
"@angular/compiler-cli": "^16.0.0",
|
||||
"@angular-devkit/build-angular": "^17.0.5",
|
||||
"@angular-eslint/builder": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
||||
"@angular-eslint/schematics": "17.1.1",
|
||||
"@angular-eslint/template-parser": "17.1.1",
|
||||
"@angular/cli": "~17.0.5",
|
||||
"@angular/compiler-cli": "^17.0.5",
|
||||
"@types/jasmine": "~4.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.2",
|
||||
"@typescript-eslint/parser": "5.59.2",
|
||||
"eslint": "^8.39.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"jasmine-core": "~4.6.0",
|
||||
@@ -52,6 +50,6 @@
|
||||
"karma-jasmine-html-reporter": "~2.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-eslint": "^15.0.1",
|
||||
"typescript": "~5.0.2"
|
||||
"typescript": "~5.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-9
@@ -3,9 +3,15 @@
|
||||
xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-project</artifactId>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-web-ui</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
<name>vripper-web-ui</name>
|
||||
|
||||
<build>
|
||||
@@ -19,27 +25,31 @@
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.12.1</version>
|
||||
<version>1.15.0</version>
|
||||
<configuration>
|
||||
<nodeVersion>v18.16.0</nodeVersion>
|
||||
<nodeVersion>v20.10.0</nodeVersion>
|
||||
<yarnVersion>v1.22.21</yarnVersion>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install node</id>
|
||||
<id>install node and yarn</id>
|
||||
<goals>
|
||||
<goal>install-node-and-npm</goal>
|
||||
<goal>install-node-and-yarn</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm install</id>
|
||||
<id>yarn install</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
<goal>yarn</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<arguments>install</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm run build</id>
|
||||
<id>yarn run build</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
<goal>yarn</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<arguments>run build</arguments>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
(startSelected)="onStartSelected()"
|
||||
(stopSelected)="onStopSelected()"
|
||||
(deleteSelected)="onDeleteSelected()"
|
||||
(renameSelected)='onRenameSelected()'
|
||||
(clearDownload)="onClearDownloads()"
|
||||
(clearLogs)="onClearLogs()"
|
||||
(clearThreads)="onClearThreads()"></app-toolbar>
|
||||
@@ -24,7 +25,8 @@
|
||||
</mat-icon>
|
||||
</ng-template>
|
||||
<app-download-table
|
||||
(selectedChange)="onPostsSelectionChange($event)"
|
||||
(selectedPostsChange)='onPostsSelectionChange($event)'
|
||||
(selectedPostChange)='onPostSelectionChange($event)'
|
||||
(rowCountChange)="downloadCount.set($event)"></app-download-table>
|
||||
</mat-tab>
|
||||
<mat-tab>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, computed, signal } from '@angular/core';
|
||||
import { Component, computed, signal, WritableSignal } from '@angular/core';
|
||||
import { ToolbarComponent } from './toolbar/toolbar.component';
|
||||
import { CommonModule, NgIf } from '@angular/common';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
@@ -16,10 +16,18 @@ import {
|
||||
} from '@angular/material/dialog';
|
||||
import { SettingsComponent } from './settings/settings.component';
|
||||
import { Settings } from './domain/settings.model';
|
||||
import { ConfirmComponent, ConfirmDialogData } from './confirm/confirm.component';
|
||||
import { Subject } from 'rxjs';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
ConfirmDialogData,
|
||||
} from './confirm/confirm.component';
|
||||
import { EMPTY, mergeMap, Subject } from 'rxjs';
|
||||
import { RxStompState } from '@stomp/rx-stomp';
|
||||
import { StatusBarComponent } from './status-bar/status-bar.component';
|
||||
import {
|
||||
RenameDialogComponent,
|
||||
RenameDialogData,
|
||||
RenameDialogResult,
|
||||
} from './rename-dialog/rename-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -46,6 +54,7 @@ export class AppComponent {
|
||||
logCount = signal(0);
|
||||
tabIndex = signal(0);
|
||||
|
||||
selectedPost: WritableSignal<Post | null> = signal(null);
|
||||
selectedPosts = signal([] as Post[]);
|
||||
noPostSelected = computed(() => this.selectedPosts().length === 0);
|
||||
loading = computed(
|
||||
@@ -68,6 +77,10 @@ export class AppComponent {
|
||||
this.selectedPosts.set(selectedPosts);
|
||||
};
|
||||
|
||||
onPostSelectionChange = (selectedPost: Post) => {
|
||||
this.selectedPost.set(selectedPost);
|
||||
};
|
||||
|
||||
onStartSelected = () => {
|
||||
if (this.selectedPosts().length > 0) {
|
||||
this.applicationEndpoint.startPosts(this.selectedPosts()).subscribe();
|
||||
@@ -96,6 +109,10 @@ export class AppComponent {
|
||||
this.applicationEndpoint.settings().subscribe(s =>
|
||||
this.dialog.open<SettingsComponent, Settings>(SettingsComponent, {
|
||||
data: s,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -115,6 +132,39 @@ export class AppComponent {
|
||||
});
|
||||
};
|
||||
|
||||
onRenameSelected = () => {
|
||||
if (this.selectedPost() == null) {
|
||||
return;
|
||||
}
|
||||
const post = this.selectedPost();
|
||||
if (post?.postId == null || post?.folderName == null) {
|
||||
return;
|
||||
}
|
||||
const dialog: MatDialogRef<RenameDialogComponent, RenameDialogResult> =
|
||||
this.dialog.open<
|
||||
RenameDialogComponent,
|
||||
RenameDialogData,
|
||||
RenameDialogResult
|
||||
>(RenameDialogComponent, {
|
||||
data: {
|
||||
postId: post.postId,
|
||||
name: post.folderName,
|
||||
},
|
||||
});
|
||||
dialog
|
||||
.afterClosed()
|
||||
.pipe(
|
||||
mergeMap(result => {
|
||||
if (result) {
|
||||
return this.applicationEndpoint.renamePost(result);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
};
|
||||
|
||||
onClearDownloads = () => {
|
||||
const confirmDialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(ConfirmComponent, {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<h2 mat-dialog-title>Confirmation</h2>
|
||||
<mat-dialog-content
|
||||
class="mat-typography"
|
||||
style="min-width: 30vw">
|
||||
<mat-dialog-content class="mat-typography" style="min-width: 30vw">
|
||||
{{ data.message }}
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="confirm()" cdkFocusInitial>Yes</button>
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>No</button>
|
||||
<button mat-flat-button mat-dialog-close cdkFocusInitial>No</button>
|
||||
<button mat-flat-button color="primary" (click)="confirm()">Yes</button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Image } from './image.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { progress, statusIcon } from '../utils/utils';
|
||||
|
||||
export class ImageRow extends Image {
|
||||
public progress: WritableSignal<number>;
|
||||
public statusIcon: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
postId: number,
|
||||
url: string,
|
||||
status: string,
|
||||
index: number,
|
||||
downloaded: number,
|
||||
size: number
|
||||
) {
|
||||
super(postId, url, status, index, downloaded, size);
|
||||
this.progress = signal(progress(downloaded, size));
|
||||
this.statusIcon = signal(statusIcon(status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Log } from './log.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
|
||||
export class LogRow extends Log {
|
||||
public statusSignal: WritableSignal<string>;
|
||||
public messageSignal: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
type: string,
|
||||
status: string,
|
||||
time: string,
|
||||
message: string
|
||||
) {
|
||||
super(id, formatType(type), status, time, message);
|
||||
this.statusSignal = signal(status);
|
||||
this.messageSignal = signal(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatType(status: string) {
|
||||
switch (status) {
|
||||
case 'POST':
|
||||
return '🖼️ New gallery';
|
||||
case 'THREAD':
|
||||
return '🧵 New thread';
|
||||
case 'THANKS':
|
||||
return '👍 Sending a like ';
|
||||
case 'SCAN':
|
||||
return '🔍 Links scan';
|
||||
case 'METADATA':
|
||||
case 'METADATA_CACHE_MISS':
|
||||
return '🗄️ Loading post metadata';
|
||||
case 'QUEUED':
|
||||
case 'QUEUED_CACHE_MISS':
|
||||
return '📋 Loading multi-post link';
|
||||
case 'DOWNLOAD':
|
||||
return '📥 Download';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Post } from './post.model';
|
||||
import { signal, WritableSignal } from '@angular/core';
|
||||
import { progress, statusIcon, totalFormatter } from '../utils/utils';
|
||||
|
||||
export class PostRow extends Post {
|
||||
public statusIcon: WritableSignal<string>;
|
||||
public progress: WritableSignal<number>;
|
||||
public total2: WritableSignal<string>;
|
||||
public path: WritableSignal<string>;
|
||||
|
||||
constructor(
|
||||
postId: number,
|
||||
postTitle: string,
|
||||
status: string,
|
||||
url: string,
|
||||
done: number,
|
||||
total: number,
|
||||
hosts: string[],
|
||||
addedOn: string,
|
||||
rank: number,
|
||||
downloadDirectory: string,
|
||||
folderName: string,
|
||||
downloadFolder: string,
|
||||
downloaded: number
|
||||
) {
|
||||
super(
|
||||
postId,
|
||||
postTitle,
|
||||
status,
|
||||
url,
|
||||
done,
|
||||
total,
|
||||
hosts,
|
||||
addedOn,
|
||||
rank,
|
||||
downloadDirectory,
|
||||
folderName,
|
||||
downloadFolder,
|
||||
downloaded
|
||||
);
|
||||
this.statusIcon = signal(statusIcon(status));
|
||||
this.progress = signal(progress(done, total));
|
||||
this.total2 = signal(totalFormatter(done, total, downloaded));
|
||||
this.path = signal(downloadFolder);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export class Post {
|
||||
public addedOn: string,
|
||||
public rank: number,
|
||||
public downloadDirectory: string,
|
||||
public folderName: string,
|
||||
public downloadFolder: string,
|
||||
public downloaded: number
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Thread } from './thread.model';
|
||||
|
||||
export class ThreadRow extends Thread {
|
||||
constructor(link: string, title: string, threadId: number, total: number) {
|
||||
super(link, title, threadId, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
@use 'sass:map';
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
@mixin color($theme) {
|
||||
.row:hover {
|
||||
background-color: mat.get-theme-color($theme, background, hover);
|
||||
}
|
||||
|
||||
.selected.row {
|
||||
background-color: mat.get-theme-color($theme, background, selected-button);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin theme($theme) {
|
||||
@if mat.theme-has($theme, color) {
|
||||
@include color($theme);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,105 @@
|
||||
<ag-grid-angular
|
||||
id="download-grid"
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions"
|
||||
(contextmenu)="disableForRows($event)">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Title
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.postTitle"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.postTitle }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
|
||||
matColumnDef="progress">
|
||||
<th *matHeaderCellDef mat-header-cell>Progress</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-progress-bar
|
||||
[value]="element.progress()"
|
||||
mode="determinate"></mat-progress-bar>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-icon
|
||||
[fontIcon]="element.statusIcon()"
|
||||
aria-hidden="false"
|
||||
color="primary"></mat-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'path')"
|
||||
matColumnDef="path">
|
||||
<th *matHeaderCellDef mat-header-cell>Path</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]='element.path()'
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.path() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
|
||||
matColumnDef="total">
|
||||
<th *matHeaderCellDef mat-header-cell>Total</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.total2() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
|
||||
matColumnDef="hosts">
|
||||
<th *matHeaderCellDef mat-header-cell>Hosts</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.hosts }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'addedOn')"
|
||||
matColumnDef="addedOn">
|
||||
<th *matHeaderCellDef mat-header-cell>AddedOn</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.addedOn }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'order')"
|
||||
matColumnDef="order">
|
||||
<th *matHeaderCellDef mat-header-cell>Order</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.rank }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row, $event)"
|
||||
(contextmenu)="onContextMenu($event, row)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
.mat-column-progress {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mat-column-status {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.mat-column-addedOn {
|
||||
width: 165px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mat-column-order {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.mat-column-total {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.mat-column-path {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mat-column-hosts {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.row {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ComponentRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import {
|
||||
CellContextMenuEvent,
|
||||
GridOptions,
|
||||
IRowNode,
|
||||
RowDataUpdatedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
ValueFormatterParams,
|
||||
} from 'ag-grid-community';
|
||||
import { Post } from '../domain/post.model';
|
||||
import { fromEvent, merge, Subscription, take } from 'rxjs';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
EMPTY,
|
||||
mergeMap,
|
||||
Observable,
|
||||
Subscription,
|
||||
take,
|
||||
} from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import {
|
||||
Overlay,
|
||||
@@ -26,289 +23,331 @@ import {
|
||||
OverlayPositionBuilder,
|
||||
} from '@angular/cdk/overlay';
|
||||
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
|
||||
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import {
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import {
|
||||
isDisplayed,
|
||||
progress,
|
||||
statusIcon,
|
||||
totalFormatter,
|
||||
} from '../utils/utils';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { PostContextmenuComponent } from '../post-contextmenu/post-contextmenu.component';
|
||||
import {
|
||||
ConfirmComponent,
|
||||
ConfirmDialogData,
|
||||
} from '../confirm/confirm.component';
|
||||
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
|
||||
import { Image } from '../domain/image.model';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { formatBytes } from '../utils/utils';
|
||||
import { PostRow } from '../domain/post-row.model';
|
||||
import {
|
||||
RenameDialogComponent,
|
||||
RenameDialogData,
|
||||
RenameDialogResult,
|
||||
} from '../rename-dialog/rename-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-download-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AgGridModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
MatDialogModule,
|
||||
ProgressCellComponent,
|
||||
MatListModule,
|
||||
MatTableModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './download-table.component.html',
|
||||
styleUrls: ['./download-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DownloadTableComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
gridOptions: GridOptions;
|
||||
export class DownloadTableComponent {
|
||||
dataSource = new PostDataSource(this.applicationEndpoint);
|
||||
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
|
||||
@Output()
|
||||
selectedChange = new EventEmitter<Post[]>();
|
||||
selectedPostsChange = new EventEmitter<Post[]>();
|
||||
@Output()
|
||||
selectedPostChange = new EventEmitter<Post>();
|
||||
|
||||
subscriptions: Subscription[] = [];
|
||||
displayedColumns: string[] = [
|
||||
'title',
|
||||
'progress',
|
||||
'status',
|
||||
'path',
|
||||
'total',
|
||||
'hosts',
|
||||
'addedOn',
|
||||
'order',
|
||||
];
|
||||
selection = new SelectionModel<PostRow>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.postId === b.postId
|
||||
);
|
||||
|
||||
isDisplayed = isDisplayed;
|
||||
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private overlayPositionBuilder: OverlayPositionBuilder,
|
||||
private overlay: Overlay,
|
||||
private dialog: MatDialog
|
||||
private dialog: MatDialog,
|
||||
private breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
headerName: 'Title',
|
||||
field: 'postTitle',
|
||||
tooltipField: 'postTitle',
|
||||
headerCheckboxSelection: true,
|
||||
headerCheckboxSelectionFilteredOnly: true,
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
headerName: 'Progress',
|
||||
tooltipValueGetter: params => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.done === 0 || params.data.total === 0
|
||||
? 0
|
||||
: (params.data.done / params.data.total) * 100;
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<Post>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.done === 0 || params.data.total === 0
|
||||
? 0
|
||||
: (params.data.done / params.data.total) * 100;
|
||||
},
|
||||
cellRenderer: ProgressCellComponent,
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
tooltipValueGetter: params => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
valueFormatter: (params: ValueFormatterParams<Image>) => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: 'Path',
|
||||
field: 'downloadDirectory',
|
||||
tooltipField: 'downloadDirectory',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Total',
|
||||
tooltipValueGetter: params =>
|
||||
`${params.data?.done}/${params.data?.total} (${formatBytes(
|
||||
params.data?.downloaded
|
||||
)})`,
|
||||
valueGetter: params =>
|
||||
`${params.data?.done}/${params.data?.total} (${formatBytes(
|
||||
params.data?.downloaded
|
||||
)})`,
|
||||
},
|
||||
{
|
||||
headerName: 'Hosts',
|
||||
field: 'hosts',
|
||||
tooltipField: 'hosts',
|
||||
valueGetter: (params: ValueGetterParams<Post>) => {
|
||||
return params.data?.hosts.join(', ');
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: 'Added On',
|
||||
field: 'addedOn',
|
||||
tooltipField: 'addedOn',
|
||||
},
|
||||
{
|
||||
headerName: 'Order',
|
||||
field: 'rank',
|
||||
tooltipField: 'rank',
|
||||
sort: 'asc',
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['postId'],
|
||||
onGridReady: () => this.connect(),
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent<Post>) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
onCellContextMenu: (event: CellContextMenuEvent<Post>) => {
|
||||
if (event.api.getSelectedRows().length > 1) {
|
||||
event.node.setSelected(true);
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
this.selection.changed.subscribe(selectionChange => {
|
||||
this.selectedPostChange.emit(selectionChange.added[0]);
|
||||
this.selectedPostsChange.emit(this.selection.selected);
|
||||
});
|
||||
this.breakpointObserver
|
||||
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.columnsToDisplay.set(['title', 'progress', 'status']);
|
||||
} else {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
const mouseEvent = event.event as MouseEvent;
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
this.columnsToDisplay.set([
|
||||
'title',
|
||||
'progress',
|
||||
'status',
|
||||
'path',
|
||||
'total',
|
||||
'hosts',
|
||||
'addedOn',
|
||||
'order',
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(
|
||||
PostContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<PostContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.post = event.data as Post;
|
||||
|
||||
ref.instance.onPostStart = () =>
|
||||
this.applicationEndpoint
|
||||
.startPosts(event.api.getSelectedRows())
|
||||
.subscribe();
|
||||
|
||||
ref.instance.onPostStop = () =>
|
||||
this.applicationEndpoint
|
||||
.stopPosts(event.api.getSelectedRows())
|
||||
.subscribe();
|
||||
|
||||
ref.instance.onPostDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
event.api.getSelectedRows().length
|
||||
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deletePosts(event.api.getSelectedRows())
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const subscription = merge(
|
||||
fromEvent<MouseEvent>(document, 'click'),
|
||||
fromEvent<MouseEvent>(document, 'contextmenu')
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
},
|
||||
onSelectionChanged: (event: SelectionChangedEvent<Post>) => {
|
||||
this.selectedChange.emit(event.api.getSelectedRows());
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<Post>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ImageDialogData = { postId: event.data.postId };
|
||||
this.dialog.open(ImagesComponent, { data });
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: PostRow): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.postId
|
||||
}`;
|
||||
}
|
||||
|
||||
onRowDoubleClicked(event: PostRow) {
|
||||
const data: ImageDialogData = { postId: event.postId };
|
||||
this.dialog.open(ImagesComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
}
|
||||
|
||||
onClick(row: PostRow, $event: MouseEvent) {
|
||||
if (!$event.ctrlKey) {
|
||||
this.selection.clear();
|
||||
}
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onContextMenu(mouseEvent: MouseEvent, row: PostRow) {
|
||||
mouseEvent.preventDefault();
|
||||
if (this.selection.selected.length > 1) {
|
||||
this.selection.select(row);
|
||||
} else {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(PostContextmenuComponent);
|
||||
const ref: ComponentRef<PostContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.post = row as PostRow;
|
||||
|
||||
ref.instance.close = () => {
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
};
|
||||
|
||||
ref.instance.onPostStart = () =>
|
||||
this.applicationEndpoint.startPosts(this.selection.selected).subscribe();
|
||||
|
||||
ref.instance.onPostStop = () =>
|
||||
this.applicationEndpoint.stopPosts(this.selection.selected).subscribe();
|
||||
|
||||
ref.instance.onPostRename = () => {
|
||||
const dialog: MatDialogRef<RenameDialogComponent, RenameDialogResult> =
|
||||
this.dialog.open<
|
||||
RenameDialogComponent,
|
||||
RenameDialogData,
|
||||
RenameDialogResult
|
||||
>(RenameDialogComponent, {
|
||||
data: { postId: row.postId, name: row.folderName },
|
||||
});
|
||||
dialog
|
||||
.afterClosed()
|
||||
.pipe(
|
||||
mergeMap(result => {
|
||||
if (result) {
|
||||
return this.applicationEndpoint.renamePost(result);
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
};
|
||||
|
||||
ref.instance.onPostDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
this.selection.selected.length
|
||||
} post${this.selection.selected.length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deletePosts(this.selection.selected)
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
postContextMenuOverlayRef
|
||||
.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
console.log('click away');
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PostDataSource extends DataSource<PostRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<PostRow[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<PostRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.newPosts$.subscribe((newPosts: Post[]) => {
|
||||
this.agGrid.api.applyTransaction({ add: newPosts });
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
...newPosts.map(
|
||||
e =>
|
||||
new PostRow(
|
||||
e.postId,
|
||||
e.postTitle,
|
||||
e.status,
|
||||
e.url,
|
||||
e.done,
|
||||
e.total,
|
||||
e.hosts,
|
||||
e.addedOn,
|
||||
e.rank,
|
||||
e.downloadDirectory,
|
||||
e.folderName,
|
||||
e.downloadFolder,
|
||||
e.downloaded
|
||||
)
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.deletedPosts$.subscribe((e: string[]) => {
|
||||
const toRemove: string[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
this.applicationEndpoint.deletedPosts$.subscribe((e: number[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.postId) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedPosts$.subscribe((e: Post[]) => {
|
||||
const toUpdate: Post[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
|
||||
String(v.postId)
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.postId === v.postId
|
||||
);
|
||||
if (rowNode != null) {
|
||||
toUpdate.push(v);
|
||||
Object.assign(rowNode, v);
|
||||
rowNode.statusIcon.set(statusIcon(v.status));
|
||||
rowNode.progress.set(progress(v.done, v.total));
|
||||
rowNode.total2.set(totalFormatter(v.done, v.total, v.downloaded));
|
||||
rowNode.path.set(v.downloadFolder);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate });
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
disableForRows(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
const element = document
|
||||
.getElementById('download-grid')
|
||||
?.getElementsByClassName('ag-center-cols-container')
|
||||
?.item(0) as HTMLElement;
|
||||
if (element.contains(target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
<h2 mat-dialog-title>Images</h2>
|
||||
<mat-dialog-content
|
||||
class="mat-typography"
|
||||
style="min-width: 50vw; min-height: 50vh">
|
||||
<ag-grid-angular
|
||||
style="width: 50vw; height: 50vh"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'index')"
|
||||
matColumnDef="index">
|
||||
<th *matHeaderCellDef mat-header-cell>Index</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.index }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
|
||||
matColumnDef="url">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
{{ element.url }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'progress')"
|
||||
matColumnDef="progress">
|
||||
<th *matHeaderCellDef mat-header-cell>Progress</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-progress-bar
|
||||
[value]="element.progress()"
|
||||
mode="determinate"></mat-progress-bar>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<mat-icon
|
||||
[fontIcon]="element.statusIcon()"
|
||||
aria-hidden="false"
|
||||
color="primary"></mat-icon>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button [mat-dialog-close]="true" cdkFocusInitial>Close</button>
|
||||
<button [mat-dialog-close]="true" cdkFocusInitial mat-flat-button>
|
||||
Close
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.mat-column-progress {
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.mat-column-status {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
.mat-column-index {
|
||||
text-align: center;
|
||||
width: 25px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Component, Inject, OnDestroy, ViewChild } from '@angular/core';
|
||||
import { Component, Inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { GridOptions, ValueFormatterParams } from 'ag-grid-community';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { Image } from '../domain/image.model';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import { ProgressCellComponent } from '../progress-cell/progress-cell.component';
|
||||
import { ITooltipParams } from 'ag-grid-community/dist/lib/rendering/tooltipComponent';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { isDisplayed, progress, statusIcon } from '../utils/utils';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { ImageRow } from '../domain/image-row.model';
|
||||
|
||||
export interface ImageDialogData {
|
||||
postId: number;
|
||||
@@ -18,100 +22,104 @@ export interface ImageDialogData {
|
||||
@Component({
|
||||
selector: 'app-images',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatDialogModule, MatButtonModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './images.component.html',
|
||||
styleUrls: ['./images.component.scss'],
|
||||
})
|
||||
export class ImagesComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
export class ImagesComponent {
|
||||
displayedColumns: string[] = ['index', 'url', 'progress', 'status'];
|
||||
selection = new SelectionModel<ImageRow>(
|
||||
false,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.url === b.url
|
||||
);
|
||||
dataSource = new ImageDataSource(this.applicationEndpoint, this.data.postId);
|
||||
|
||||
gridOptions: GridOptions<Image>;
|
||||
subscriptions: Subscription[] = [];
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
isDisplayed = isDisplayed;
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ImageDialogData,
|
||||
private applicationEndpoint: ApplicationEndpointService
|
||||
public dialogRef: DialogRef<ImageDialogData>,
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
headerName: '#',
|
||||
field: 'index',
|
||||
tooltipField: 'index',
|
||||
sort: 'asc',
|
||||
},
|
||||
{
|
||||
headerName: 'URL',
|
||||
field: 'url',
|
||||
tooltipField: 'url',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Progress',
|
||||
field: 'progress',
|
||||
tooltipValueGetter: (params: ITooltipParams<Image>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.downloaded === 0 || params.data.size === 0
|
||||
? 0
|
||||
: (params.data.downloaded / params.data.size) * 100;
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<Image>) => {
|
||||
if (!params.data) {
|
||||
return 0;
|
||||
}
|
||||
return params.data.downloaded === 0 || params.data.size === 0
|
||||
? 0
|
||||
: (params.data.downloaded / params.data.size) * 100;
|
||||
},
|
||||
cellRenderer: ProgressCellComponent,
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
valueFormatter: (params: ValueFormatterParams<Image>) => {
|
||||
const value = params.value as string;
|
||||
return (
|
||||
value.at(0)?.toUpperCase() +
|
||||
value.substring(1, value.length).toLowerCase()
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
getRowId: row => row.data['url'].toString(),
|
||||
onGridReady: () => this.connect(),
|
||||
};
|
||||
breakpointObserver
|
||||
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.columnsToDisplay.set(['index', 'progress', 'status']);
|
||||
} else {
|
||||
this.columnsToDisplay.set(['index', 'url', 'progress', 'status']);
|
||||
}
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
this.dialogRef.updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
onClick(row: ImageRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
}
|
||||
|
||||
class ImageDataSource extends DataSource<ImageRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<ImageRow[]>([]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private postId: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<ImageRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint
|
||||
.postDetails$(this.data.postId)
|
||||
.postDetails$(this.postId)
|
||||
.subscribe((e: Image[]) => {
|
||||
const toAdd: Image[] = [];
|
||||
const toUpdate: Image[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(v.url.toString()) == null) {
|
||||
toAdd.push(v);
|
||||
e.forEach(image => {
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.url === image.url
|
||||
);
|
||||
if (rowNode == null) {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
new ImageRow(
|
||||
image.postId,
|
||||
image.url,
|
||||
image.status,
|
||||
image.index,
|
||||
image.downloaded,
|
||||
image.size
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
toUpdate.push(v);
|
||||
Object.assign(rowNode, image);
|
||||
rowNode.statusIcon.set(statusIcon(image.status));
|
||||
rowNode.progress.set(progress(image.downloaded, image.size));
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
<ag-grid-angular
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'time')"
|
||||
matColumnDef="time">
|
||||
<th *matHeaderCellDef mat-header-cell>Time</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.time"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.time }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'type')"
|
||||
matColumnDef="type">
|
||||
<th *matHeaderCellDef mat-header-cell>Type</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.type"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.type }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'status')"
|
||||
matColumnDef="status">
|
||||
<th *matHeaderCellDef mat-header-cell>Status</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.statusSignal()"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.statusSignal() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'message')"
|
||||
matColumnDef="message">
|
||||
<th *matHeaderCellDef mat-header-cell>Message</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.messageSignal()"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.messageSignal() }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -3,142 +3,109 @@ import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { GridOptions, IRowNode, RowDataUpdatedEvent } from 'ag-grid-community';
|
||||
import { Observable, Subscription } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { Log } from '../domain/log.model';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { formatType, LogRow } from '../domain/log-row.model';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
@Component({
|
||||
selector: 'app-log-table',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule],
|
||||
imports: [CommonModule, MatCheckboxModule, MatTableModule],
|
||||
templateUrl: './log-table.component.html',
|
||||
styleUrls: ['./log-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LogTableComponent implements OnInit, OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
|
||||
export class LogTableComponent implements OnInit {
|
||||
dataSource = new LogDataSource(this.applicationEndpoint);
|
||||
displayedColumns: string[] = ['time', 'type', 'status', 'message'];
|
||||
selection = new SelectionModel<LogRow>(
|
||||
false,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.id === b.id
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
|
||||
@Input({ required: true })
|
||||
clear!: Observable<void>;
|
||||
|
||||
gridOptions: GridOptions;
|
||||
subscriptions: Subscription[] = [];
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
headerName: 'Time',
|
||||
field: 'time',
|
||||
tooltipField: 'time',
|
||||
sort: 'desc',
|
||||
},
|
||||
{
|
||||
headerName: 'Type',
|
||||
field: 'type',
|
||||
tooltipField: 'type',
|
||||
valueGetter: params => {
|
||||
switch (params.data.type) {
|
||||
case 'POST':
|
||||
return '🖼️ New gallery';
|
||||
case 'THREAD':
|
||||
return '🧵 New thread';
|
||||
case 'THANKS':
|
||||
return '👍 Sending a like ';
|
||||
case 'SCAN':
|
||||
return '🔍 Links scan';
|
||||
case 'METADATA':
|
||||
case 'METADATA_CACHE_MISS':
|
||||
return '🗄️ Loading post metadata';
|
||||
case 'QUEUED':
|
||||
case 'QUEUED_CACHE_MISS':
|
||||
return '📋 Loading multi-post link';
|
||||
case 'DOWNLOAD':
|
||||
return '📥 Download';
|
||||
default:
|
||||
return params.data.type;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
tooltipField: 'status',
|
||||
},
|
||||
{
|
||||
headerName: 'Message',
|
||||
field: 'message',
|
||||
tooltipField: 'message',
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'single',
|
||||
getRowId: row => row.data['id'].toString(),
|
||||
onGridReady: () => this.connect(),
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
};
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.clear.subscribe(() => this.agGrid.api.setRowData([]));
|
||||
this.clear.subscribe(() => {
|
||||
this.dataSource._dataStream.next([]);
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.newLogs$.subscribe((e: Log[]) => {
|
||||
this.agGrid.api.applyTransaction({ add: e });
|
||||
})
|
||||
);
|
||||
onClick(row: LogRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
}
|
||||
|
||||
class LogDataSource extends DataSource<LogRow> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<LogRow[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<LogRow[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
|
||||
const toUpdate: Log[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(v.id.toString()) != null) {
|
||||
toUpdate.push(v);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate });
|
||||
this.applicationEndpoint.newLogs$.subscribe((newLogs: Log[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
...newLogs.map(
|
||||
e => new LogRow(e.id, e.type, e.status, e.time, e.message)
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.logsRemove$.subscribe((e: number[]) => {
|
||||
const toRemove: any[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(
|
||||
v.toString()
|
||||
);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.id) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.updatedLogs$.subscribe((e: Log[]) => {
|
||||
e.forEach(v => {
|
||||
const rowNode = this._dataStream.value.find(d => d.id === v.id);
|
||||
if (rowNode != null) {
|
||||
Object.assign(rowNode, v);
|
||||
rowNode.type = formatType(v.type);
|
||||
rowNode.statusSignal.set(v.status);
|
||||
rowNode.messageSignal.set(v.message);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
post.status === 'ERROR' ||
|
||||
post.status === 'STOPPED'
|
||||
"
|
||||
(click)="onPostStart()">
|
||||
(click)="onPostStart(); close()">
|
||||
<mat-icon matListItemIcon>play_arrow</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ post.done / post.total === 0 ? 'Start' : 'Resume' }}
|
||||
@@ -18,15 +18,19 @@
|
||||
post.status === 'PARTIAL' ||
|
||||
post.status === 'PENDING'
|
||||
"
|
||||
(click)="onPostStop()">
|
||||
(click)="onPostStop(); close()">
|
||||
<mat-icon matListItemIcon>pause</mat-icon>
|
||||
<div matListItemTitle>Stop</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="onPostDelete()">
|
||||
<mat-list-item (click)='onPostRename(); close()'>
|
||||
<mat-icon matListItemIcon>edit</mat-icon>
|
||||
<div matListItemTitle>Rename</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="onPostDelete(); close()">
|
||||
<mat-icon matListItemIcon>delete</mat-icon>
|
||||
<div matListItemTitle>Remove</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="openImages()">
|
||||
<mat-list-item (click)="openImages(); close()">
|
||||
<mat-icon matListItemIcon>list</mat-icon>
|
||||
<div matListItemTitle>Photos</div>
|
||||
</mat-list-item>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { CommonModule, NgIf } from '@angular/common';
|
||||
import { Post } from '../domain/post.model';
|
||||
import {
|
||||
animate,
|
||||
state,
|
||||
@@ -18,6 +12,7 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
import { PostRow } from '../domain/post-row.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-post-contextmenu',
|
||||
@@ -35,18 +30,24 @@ import { ImageDialogData, ImagesComponent } from '../images/images.component';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PostContextmenuComponent {
|
||||
post!: Post;
|
||||
post!: PostRow;
|
||||
|
||||
constructor(public dialog: MatDialog) {}
|
||||
|
||||
openImages = () => {
|
||||
const data: ImageDialogData = { postId: this.post.postId };
|
||||
this.dialog.open(ImagesComponent, { data });
|
||||
this.dialog.open(ImagesComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
};
|
||||
|
||||
onPostStart!: () => void;
|
||||
|
||||
onPostStop!: () => void;
|
||||
|
||||
onPostRename!: () => void;
|
||||
onPostDelete!: () => void;
|
||||
close!: () => void;
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<div style="display: flex; height: 100%; align-items: center">
|
||||
<mat-progress-bar mode="determinate" [value]="progress()"></mat-progress-bar>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProgressCellComponent } from './progress-cell.component';
|
||||
|
||||
describe('ProgressCellComponent', () => {
|
||||
let component: ProgressCellComponent;
|
||||
let fixture: ComponentFixture<ProgressCellComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ProgressCellComponent]
|
||||
});
|
||||
fixture = TestBed.createComponent(ProgressCellComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ICellRendererParams } from 'ag-grid-community';
|
||||
import { AgGridModule, ICellRendererAngularComp } from 'ag-grid-angular';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-progress-cell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatProgressBarModule],
|
||||
templateUrl: './progress-cell.component.html',
|
||||
styleUrls: ['./progress-cell.component.scss'],
|
||||
})
|
||||
export class ProgressCellComponent implements ICellRendererAngularComp {
|
||||
progress = signal(0);
|
||||
agInit(params: ICellRendererParams): void {
|
||||
this.progress.set(params.value);
|
||||
}
|
||||
|
||||
refresh(params: ICellRendererParams): boolean {
|
||||
this.progress.set(params.value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<h2 mat-dialog-title>Confirmation</h2>
|
||||
<mat-dialog-content class='mat-typography' style='min-width: 30vw'>
|
||||
<form>
|
||||
<mat-form-field appearance='outline' style='width: 100%; padding-top: 20px'>
|
||||
<mat-label>Folder name</mat-label>
|
||||
<input [formControl]='formControl' matInput name='folderName' required />
|
||||
</mat-form-field>
|
||||
</form>
|
||||
<mat-dialog-actions align='end'>
|
||||
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
|
||||
<button mat-flat-button color='primary' (click)='confirm()'>Ok</button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { RenameDialogComponent } from './rename-dialog.component';
|
||||
|
||||
describe('ConfirmComponent', () => {
|
||||
let component: RenameDialogComponent;
|
||||
let fixture: ComponentFixture<RenameDialogComponent>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RenameDialogComponent],
|
||||
});
|
||||
fixture = TestBed.createComponent(RenameDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
export interface RenameDialogData {
|
||||
postId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface RenameDialogResult {
|
||||
postId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-rename-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
FormsModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
templateUrl: './rename-dialog.component.html',
|
||||
styleUrls: ['./rename-dialog.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RenameDialogComponent {
|
||||
formControl = new FormControl(this.data.name);
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<RenameDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: RenameDialogData
|
||||
) {}
|
||||
|
||||
confirm() {
|
||||
this.dialogRef.close({
|
||||
postId: this.data.postId,
|
||||
name: this.formControl.value,
|
||||
} as RenameDialogResult);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
<mat-dialog-content
|
||||
class="mat-typography"
|
||||
style="min-width: 50vw; min-height: 50vh">
|
||||
<mat-form-field appearance="outline" style="width: 100%; height: 100%; padding-top: 20px">
|
||||
<mat-form-field appearance="outline" style="width: 100%; padding-top: 20px">
|
||||
<mat-label>Add new links</mat-label>
|
||||
<textarea
|
||||
[formControl]="formControl"
|
||||
cdkTextareaAutosize
|
||||
cdkAutosizeMinRows="5"
|
||||
[cdkTextareaAutosize]="true"
|
||||
[cdkAutosizeMinRows]="5"
|
||||
(paste)="onPaste($event)"
|
||||
matInput
|
||||
name="url"
|
||||
placeholder="Put each link in a new line"
|
||||
@@ -15,8 +16,11 @@
|
||||
</mat-form-field>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Cancel</button>
|
||||
<button mat-button [mat-dialog-close]="formControl.value" cdkFocusInitial>
|
||||
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
[mat-dialog-close]="formControl.value">
|
||||
Scan
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-scan',
|
||||
@@ -15,11 +16,33 @@ import { TextFieldModule } from '@angular/cdk/text-field';
|
||||
MatButtonModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule,
|
||||
TextFieldModule,
|
||||
],
|
||||
templateUrl: './scan.component.html',
|
||||
styleUrls: ['./scan.component.scss'],
|
||||
})
|
||||
export class ScanComponent {
|
||||
formControl = new FormControl<string>('');
|
||||
constructor(
|
||||
public dialogRef: DialogRef<never>,
|
||||
breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
breakpointObserver
|
||||
.observe(Breakpoints.HandsetPortrait)
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
this.dialogRef.updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
onPaste(event: ClipboardEvent) {
|
||||
event.preventDefault();
|
||||
const oldValue = this.formControl.value || '';
|
||||
this.formControl.setValue(
|
||||
`${oldValue}${event?.clipboardData?.getData('text')}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,9 @@ export class ApplicationEndpointService {
|
||||
return this.updatedPosts;
|
||||
}
|
||||
|
||||
private deletedPosts!: Observable<string[]>;
|
||||
private deletedPosts!: Observable<number[]>;
|
||||
|
||||
get deletedPosts$(): Observable<string[]> {
|
||||
get deletedPosts$(): Observable<number[]> {
|
||||
return this.deletedPosts;
|
||||
}
|
||||
|
||||
@@ -207,7 +207,6 @@ export class ApplicationEndpointService {
|
||||
|
||||
this.newPosts = this.rxStomp.watch('/topic/posts/new').pipe(
|
||||
map(e => {
|
||||
// const posts: Array<Post> = [];
|
||||
return JSON.parse(e.body).map((element: any) => {
|
||||
return new Post(
|
||||
element.postId,
|
||||
@@ -220,6 +219,8 @@ export class ApplicationEndpointService {
|
||||
element.addedOn,
|
||||
element.rank + 1,
|
||||
element.downloadDirectory,
|
||||
element.folderName,
|
||||
element.downloadFolder,
|
||||
element.downloaded
|
||||
);
|
||||
});
|
||||
@@ -229,7 +230,6 @@ export class ApplicationEndpointService {
|
||||
|
||||
this.updatedPosts = this.rxStomp.watch('/topic/posts/updated').pipe(
|
||||
map(e => {
|
||||
// const posts: Array<Post> = [];
|
||||
return JSON.parse(e.body).map((element: any) => {
|
||||
return new Post(
|
||||
element.postId,
|
||||
@@ -242,6 +242,8 @@ export class ApplicationEndpointService {
|
||||
element.addedOn,
|
||||
element.rank + 1,
|
||||
element.downloadDirectory,
|
||||
element.folderName,
|
||||
element.downloadFolder,
|
||||
element.downloaded
|
||||
);
|
||||
});
|
||||
@@ -309,9 +311,9 @@ export class ApplicationEndpointService {
|
||||
}
|
||||
|
||||
getThreadPosts(threadId: number) {
|
||||
return this.httpClient
|
||||
.get<PostItem[]>(this.baseUrl + `/api/grab/${threadId}`)
|
||||
.pipe(map(v => v.map(p => ({ ...p, hosts: p.hosts }))));
|
||||
return this.httpClient.get<PostItem[]>(
|
||||
this.baseUrl + `/api/grab/${threadId}`
|
||||
);
|
||||
}
|
||||
|
||||
startDownload() {
|
||||
@@ -372,4 +374,8 @@ export class ApplicationEndpointService {
|
||||
clearThreads() {
|
||||
return this.httpClient.get<void>(this.baseUrl + '/api/grab/clear');
|
||||
}
|
||||
|
||||
renamePost(data: { postId: number; name: string }) {
|
||||
return this.httpClient.post<void>(this.baseUrl + '/api/post/rename', data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,22 +128,22 @@
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Global concurrent downloads</mat-label>
|
||||
<input
|
||||
formControlName='maxConcurrentPerHost'
|
||||
formControlName="maxConcurrentPerHost"
|
||||
matInput
|
||||
max="12"
|
||||
min="0"
|
||||
name='maxConcurrentPerHost'
|
||||
name="maxConcurrentPerHost"
|
||||
required
|
||||
type="number" />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Concurrent downloads per host</mat-label>
|
||||
<input
|
||||
formControlName='maxGlobalConcurrent'
|
||||
formControlName="maxGlobalConcurrent"
|
||||
matInput
|
||||
max="4"
|
||||
min="1"
|
||||
name='maxGlobalConcurrent'
|
||||
name="maxGlobalConcurrent"
|
||||
required
|
||||
type="number" />
|
||||
</mat-form-field>
|
||||
@@ -229,9 +229,9 @@
|
||||
</div>
|
||||
</form>
|
||||
</mat-tab>
|
||||
<mat-tab label='System'>
|
||||
<mat-tab label="System">
|
||||
<form
|
||||
[formGroup]='systemSettingsForm'
|
||||
[formGroup]="systemSettingsForm"
|
||||
autocomplete="off"
|
||||
style="
|
||||
display: flex;
|
||||
@@ -242,17 +242,17 @@
|
||||
">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Temporary Path</mat-label>
|
||||
<input formControlName='tempPath' matInput name='tempPath' required />
|
||||
<input formControlName="tempPath" matInput name="tempPath" required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance='outline'>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Cache Path</mat-label>
|
||||
<input
|
||||
formControlName='cachePath'
|
||||
formControlName="cachePath"
|
||||
matInput
|
||||
name='cachePath'
|
||||
name="cachePath"
|
||||
required />
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance='outline'>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Maximum log entries</mat-label>
|
||||
<input
|
||||
formControlName="maxEventLog"
|
||||
@@ -268,6 +268,6 @@
|
||||
</mat-tab-group>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close>Cancel</button>
|
||||
<button (click)="save()" mat-button cdkFocusInitial>Save</button>
|
||||
<button mat-flat-button mat-dialog-close cdkFocusInitial>Cancel</button>
|
||||
<button (click)="save()" mat-flat-button color="primary">Save</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ViperSettings,
|
||||
} from '../domain/settings.model';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -87,13 +89,23 @@ export class SettingsComponent {
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: Settings,
|
||||
public dialogRef: MatDialogRef<SettingsComponent>,
|
||||
private applicationEndpoint: ApplicationEndpointService
|
||||
public dialogRef: DialogRef<Settings>,
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
breakpointObserver: BreakpointObserver
|
||||
) {
|
||||
this.viperGirlsSettingsForm.reset(data.viperSettings);
|
||||
this.downloadSettingsForm.reset(data.downloadSettings);
|
||||
this.connectionSettingsForm.reset(data.connectionSettings);
|
||||
this.systemSettingsForm.reset(data.systemSettings);
|
||||
breakpointObserver
|
||||
.observe(Breakpoints.HandsetPortrait)
|
||||
.subscribe(result => {
|
||||
if (result.matches) {
|
||||
this.dialogRef.updateSize('100vw', '80vh');
|
||||
} else {
|
||||
this.dialogRef.updateSize('80vw', '80vh');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
save = () => {
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<div class='status-bar' style='display: flex'>
|
||||
<span style='padding-left: 10px; flex-grow: 1'>
|
||||
<ng-container *ngIf='vgUsername$ | async as vgUsername'>
|
||||
<span>{{
|
||||
vgUsername.length > 0 ? 'Logged in as: ' + vgUsername : ''
|
||||
<div class="status-bar" style="display: flex; justify-content: space-between">
|
||||
<div>
|
||||
<span
|
||||
*ngIf="!(handsetPortrait$ | async)?.matches"
|
||||
style="padding-left: 10px; flex-grow: 1">
|
||||
<ng-container *ngIf="vgUsername$ | async as vgUsername">
|
||||
<span>{{
|
||||
vgUsername.length > 0 ? 'Logged in as: ' + vgUsername : ''
|
||||
}}</span>
|
||||
</ng-container>
|
||||
</span>
|
||||
<span *ngIf='downloadSpeed$ | async as downloadSpeed'>{{
|
||||
(downloadSpeed.speed | downloadSpeed) + '/s'
|
||||
</ng-container>
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: flex">
|
||||
<span *ngIf="downloadSpeed$ | async as downloadSpeed">{{
|
||||
(downloadSpeed.speed | downloadSpeed) + '/s'
|
||||
}}</span>
|
||||
<mat-divider [vertical]='true' style='height: 20px'></mat-divider>
|
||||
<ng-container *ngIf='queueState$ | async as queueState'>
|
||||
<span>Downloading: {{ queueState.running }}</span>
|
||||
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
|
||||
<span>Pending: {{ queueState.remaining }}</span>
|
||||
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
|
||||
</ng-container>
|
||||
<span *ngIf='errorCount$ | async as errorCount' style='padding-right: 10px'
|
||||
>Error: {{ errorCount.count }}</span
|
||||
>
|
||||
<ng-container *ngIf="queueState$ | async as queueState">
|
||||
<ng-container *ngIf="!(handsetPortrait$ | async)?.matches">
|
||||
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
|
||||
<span>Downloading: {{ queueState.running }}</span>
|
||||
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
|
||||
<span>Pending: {{ queueState.remaining }}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="errorCount$ | async as errorCount">
|
||||
<ng-container *ngIf="!(handsetPortrait$ | async)?.matches">
|
||||
<mat-divider [vertical]="true" style="height: 20px"></mat-divider>
|
||||
<span style="padding-right: 10px">Error: {{ errorCount.count }}</span>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommonModule, NgIf } from '@angular/common';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { DownloadSpeedPipe } from '../pipes/download-speed.pipe';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
|
||||
@Component({
|
||||
selector: 'app-status-bar',
|
||||
@@ -12,7 +13,13 @@ import { DownloadSpeedPipe } from '../pipes/download-speed.pipe';
|
||||
styleUrls: ['./status-bar.component.scss'],
|
||||
})
|
||||
export class StatusBarComponent {
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {}
|
||||
handsetPortrait$ = this.breakpointObserver.observe(
|
||||
Breakpoints.HandsetPortrait
|
||||
);
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private breakpointObserver: BreakpointObserver
|
||||
) {}
|
||||
|
||||
queueState$ = this.applicationEndpoint.queueState$;
|
||||
downloadSpeed$ = this.applicationEndpoint.downloadSpeed$;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<mat-card class="mat-elevation-z4">
|
||||
<mat-action-list>
|
||||
<mat-list-item (click)="onThreadSelection()">
|
||||
<mat-list-item (click)="onThreadSelection(); close()">
|
||||
<mat-icon matListItemIcon>check_box</mat-icon>
|
||||
<div matListItemTitle>Select posts</div>
|
||||
</mat-list-item>
|
||||
<mat-list-item (click)="onThreadDelete()">
|
||||
<mat-list-item (click)="onThreadDelete(); close()">
|
||||
<mat-icon matListItemIcon>delete</mat-icon>
|
||||
<div matListItemTitle>Remove</div>
|
||||
</mat-list-item>
|
||||
|
||||
@@ -5,10 +5,6 @@ import { MatListModule } from '@angular/material/list';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import {
|
||||
ThreadDialogData,
|
||||
ThreadSelectionComponent,
|
||||
} from '../thread-selection/thread-selection.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-thread-contextmenu',
|
||||
@@ -31,4 +27,5 @@ export class ThreadContextmenuComponent {
|
||||
onThreadSelection!: () => void;
|
||||
|
||||
onThreadDelete!: () => void;
|
||||
close!: () => void;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,89 @@
|
||||
<h2 mat-dialog-title>Thread</h2>
|
||||
<mat-dialog-content
|
||||
class="mat-typography"
|
||||
style="min-width: 50vw; min-height: 50vh">
|
||||
<ag-grid-angular
|
||||
style="width: 50vw; height: 50vh"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions">
|
||||
</ag-grid-angular>
|
||||
<mat-dialog-content class="mat-typography">
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'number')"
|
||||
matColumnDef="number">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Number
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.number"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.number }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>Title</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.title"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.title }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'url')"
|
||||
matColumnDef="url">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.url"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.url }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'hosts')"
|
||||
matColumnDef="hosts">
|
||||
<th *matHeaderCellDef mat-header-cell>URL</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="formatHosts(element.hosts)"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ formatHosts(element.hosts) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row, $event)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@if (dataSource.loading()) {
|
||||
<div style="display: flex; justify-content: center">
|
||||
<p>Loading</p>
|
||||
</div>
|
||||
|
||||
}
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="downloadSelected()" cdkFocusInitial>Download</button>
|
||||
<button mat-flat-button [mat-dialog-close]="true" cdkFocusInitial>
|
||||
Cancel
|
||||
</button>
|
||||
<button (click)="downloadSelected()" color="primary" mat-flat-button>
|
||||
Download
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { Component, Inject, ViewChild } from '@angular/core';
|
||||
import { Component, EventEmitter, Inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import {
|
||||
GridOptions,
|
||||
ITooltipParams,
|
||||
RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { GridReadyEvent } from 'ag-grid-community/dist/lib/events';
|
||||
import { PostItem } from '../domain/post-item.model';
|
||||
import { ValueGetterParams } from 'ag-grid-community/dist/lib/entities/colDef';
|
||||
import { DialogRef } from '@angular/cdk/dialog';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { BehaviorSubject, finalize, Observable } from 'rxjs';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
|
||||
export interface ThreadDialogData {
|
||||
threadId: number;
|
||||
@@ -24,85 +19,111 @@ export interface ThreadDialogData {
|
||||
@Component({
|
||||
selector: 'app-thread-selection',
|
||||
standalone: true,
|
||||
imports: [CommonModule, AgGridModule, MatButtonModule, MatDialogModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './thread-selection.component.html',
|
||||
styleUrls: ['./thread-selection.component.scss'],
|
||||
})
|
||||
export class ThreadSelectionComponent {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular<PostItem>;
|
||||
|
||||
gridOptions: GridOptions;
|
||||
dataSource = new ThreadSelectionDataSource(
|
||||
this.applicationEndpoint,
|
||||
this.data.threadId
|
||||
);
|
||||
displayedColumns: string[] = ['number', 'title', 'url', 'hosts'];
|
||||
selection = new SelectionModel<PostItem>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.url === b.url
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
selectedChange = new EventEmitter<Thread[]>();
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: ThreadDialogData,
|
||||
private dialogRef: MatDialogRef<ThreadSelectionComponent>,
|
||||
private dialogRef: DialogRef<ThreadDialogData>,
|
||||
private applicationEndpoint: ApplicationEndpointService
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
{
|
||||
headerName: 'Number',
|
||||
field: 'number',
|
||||
tooltipField: 'number',
|
||||
checkboxSelection: true,
|
||||
headerCheckboxSelection: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Title',
|
||||
field: 'title',
|
||||
tooltipField: 'title',
|
||||
flex: 2,
|
||||
},
|
||||
{
|
||||
headerName: 'URL',
|
||||
field: 'url',
|
||||
tooltipField: 'url',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
headerName: 'Hosts',
|
||||
field: 'hosts',
|
||||
tooltipValueGetter: (params: ITooltipParams<PostItem>) => {
|
||||
return params.data?.hosts
|
||||
.map(v => `${v.first} (${v.second})`)
|
||||
.join(', ');
|
||||
},
|
||||
valueGetter: (params: ValueGetterParams<PostItem>) => {
|
||||
return params.data?.hosts
|
||||
.map(v => `${v.first} (${v.second})`)
|
||||
.join(', ');
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['postId'].toString(),
|
||||
onGridReady: (event: GridReadyEvent<PostItem>) => {
|
||||
this.applicationEndpoint
|
||||
.getThreadPosts(this.data.threadId)
|
||||
.subscribe(result => {
|
||||
event.api.applyTransaction({ add: result });
|
||||
});
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<PostItem>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
this.download([event.data]);
|
||||
},
|
||||
};
|
||||
) {}
|
||||
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: PostItem): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.url
|
||||
}`;
|
||||
}
|
||||
|
||||
onClick(row: PostItem, event: MouseEvent) {
|
||||
if (!event.ctrlKey) {
|
||||
this.selection.clear();
|
||||
}
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onRowDoubleClicked(row: PostItem) {
|
||||
this.download([row]);
|
||||
}
|
||||
|
||||
downloadSelected() {
|
||||
this.download(this.agGrid.api.getSelectedRows());
|
||||
this.download(this.selection.selected);
|
||||
}
|
||||
|
||||
formatHosts = (hosts: [{ first: string; second: number }]): string => {
|
||||
return hosts.map(v => `${v.first} (${v.second})`).join(', ');
|
||||
};
|
||||
|
||||
private download = (items: PostItem[]) => {
|
||||
this.applicationEndpoint.download(items).subscribe(() => {
|
||||
this.dialogRef.close();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
class ThreadSelectionDataSource extends DataSource<PostItem> {
|
||||
_dataStream = new BehaviorSubject<PostItem[]>([]);
|
||||
loading = signal(true);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
private threadId: number
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<PostItem[]> {
|
||||
this.applicationEndpoint
|
||||
.getThreadPosts(this.threadId)
|
||||
.pipe(finalize(() => this.loading.set(false)))
|
||||
.subscribe(result => {
|
||||
this._dataStream.next(result);
|
||||
});
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
<ag-grid-angular
|
||||
id="thread-grid"
|
||||
style="width: 100%; height: 100%"
|
||||
class="ag-theme-alpine"
|
||||
#agGrid
|
||||
[gridOptions]="gridOptions"
|
||||
(contextmenu)="disableForRows($event)">
|
||||
</ag-grid-angular>
|
||||
<table [dataSource]="dataSource" class="mat-elevation-z4" mat-table>
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'title')"
|
||||
matColumnDef="title">
|
||||
<th *matHeaderCellDef mat-header-cell>
|
||||
<mat-checkbox
|
||||
(change)="$event ? toggleAllRows() : null"
|
||||
[aria-label]="checkboxLabel()"
|
||||
[checked]="selection.hasValue() && isAllSelected()"
|
||||
[indeterminate]="selection.hasValue() && !isAllSelected()"
|
||||
color="primary">
|
||||
</mat-checkbox>
|
||||
Title
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.title"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.title }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'link')"
|
||||
matColumnDef="link">
|
||||
<th *matHeaderCellDef mat-header-cell>Link</th>
|
||||
<td
|
||||
*matCellDef="let element"
|
||||
[title]="element.link"
|
||||
class="truncate-cell"
|
||||
mat-cell>
|
||||
{{ element.link }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container
|
||||
*ngIf="isDisplayed(columnsToDisplay(), 'total')"
|
||||
matColumnDef="total">
|
||||
<th *matHeaderCellDef mat-header-cell>Total</th>
|
||||
<td *matCellDef="let element" class="truncate-cell" mat-cell>
|
||||
{{ element.total }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="columnsToDisplay()" mat-header-row></tr>
|
||||
<tr
|
||||
(click)="onClick(row)"
|
||||
(contextmenu)="onContextMenu($event, row)"
|
||||
(dblclick)="onRowDoubleClicked(row)"
|
||||
*matRowDef="let row; columns: columnsToDisplay()"
|
||||
[ngClass]="{ selected: selection.isSelected(row) }"
|
||||
class="row"
|
||||
mat-row></tr>
|
||||
</table>
|
||||
|
||||
@@ -3,20 +3,11 @@ import {
|
||||
Component,
|
||||
ComponentRef,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AgGridAngular, AgGridModule } from 'ag-grid-angular';
|
||||
import {
|
||||
CellContextMenuEvent,
|
||||
GridOptions,
|
||||
IRowNode,
|
||||
RowDataUpdatedEvent,
|
||||
RowDoubleClickedEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { fromEvent, merge, Subscription, take } from 'rxjs';
|
||||
import { BehaviorSubject, Observable, Subscription, take } from 'rxjs';
|
||||
import { ApplicationEndpointService } from '../services/application-endpoint.service';
|
||||
import { Thread } from '../domain/thread.model';
|
||||
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
|
||||
@@ -39,29 +30,46 @@ import {
|
||||
ConfirmComponent,
|
||||
ConfirmDialogData,
|
||||
} from '../confirm/confirm.component';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { DataSource, SelectionModel } from '@angular/cdk/collections';
|
||||
import { isDisplayed } from '../utils/utils';
|
||||
import { ThreadRow } from '../domain/thread-row.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-thread-table',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AgGridModule,
|
||||
OverlayModule,
|
||||
PortalModule,
|
||||
MatDialogModule,
|
||||
MatCheckboxModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatTableModule,
|
||||
],
|
||||
templateUrl: './thread-table.component.html',
|
||||
styleUrls: ['./thread-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ThreadTableComponent implements OnDestroy {
|
||||
@ViewChild('agGrid') agGrid!: AgGridAngular;
|
||||
|
||||
export class ThreadTableComponent {
|
||||
dataSource = new ThreadDataSource(this.applicationEndpoint);
|
||||
displayedColumns: string[] = ['title', 'link', 'total'];
|
||||
selection = new SelectionModel<ThreadRow>(
|
||||
true,
|
||||
[],
|
||||
true,
|
||||
(a, b) => a.link === b.link
|
||||
);
|
||||
isDisplayed = isDisplayed;
|
||||
@Output()
|
||||
rowCountChange = new EventEmitter<number>();
|
||||
|
||||
gridOptions: GridOptions;
|
||||
subscriptions: Subscription[] = [];
|
||||
@Output()
|
||||
selectedChange = new EventEmitter<Thread[]>();
|
||||
columnsToDisplay = signal([...this.displayedColumns]);
|
||||
|
||||
constructor(
|
||||
private applicationEndpoint: ApplicationEndpointService,
|
||||
@@ -69,158 +77,138 @@ export class ThreadTableComponent implements OnDestroy {
|
||||
private overlay: Overlay,
|
||||
private dialog: MatDialog
|
||||
) {
|
||||
this.gridOptions = <GridOptions>{
|
||||
columnDefs: [
|
||||
this.dataSource._dataStream.subscribe(v =>
|
||||
this.rowCountChange.emit(v.length)
|
||||
);
|
||||
this.selection.changed.subscribe(() =>
|
||||
this.selectedChange.emit(this.selection.selected)
|
||||
);
|
||||
}
|
||||
|
||||
onRowDoubleClicked(row: ThreadRow) {
|
||||
const data: ThreadDialogData = { threadId: row.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
}
|
||||
|
||||
onClick(row: ThreadRow) {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
|
||||
onContextMenu(mouseEvent: MouseEvent, row: ThreadRow) {
|
||||
mouseEvent.preventDefault();
|
||||
if (this.selection.selected.length > 1) {
|
||||
this.selection.select(row);
|
||||
} else {
|
||||
this.selection.clear();
|
||||
this.selection.select(row);
|
||||
}
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
headerName: 'Title',
|
||||
field: 'title',
|
||||
tooltipField: 'title',
|
||||
flex: 1,
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
headerName: 'Url',
|
||||
field: 'link',
|
||||
tooltipField: 'link',
|
||||
flex: 1,
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
{
|
||||
headerName: 'Count',
|
||||
field: 'total',
|
||||
tooltipField: 'total',
|
||||
},
|
||||
],
|
||||
defaultColDef: {
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
rowSelection: 'multiple',
|
||||
getRowId: row => row.data['threadId'],
|
||||
onGridReady: () => this.connect(),
|
||||
onRowDataUpdated: (event: RowDataUpdatedEvent) =>
|
||||
this.rowCountChange.emit(event.api.getDisplayedRowCount()),
|
||||
onCellContextMenu: (event: CellContextMenuEvent<Thread>) => {
|
||||
if (event.api.getSelectedRows().length > 1) {
|
||||
event.node.setSelected(true);
|
||||
} else {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
const mouseEvent = event.event as MouseEvent;
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo({ x: mouseEvent.x, y: mouseEvent.y })
|
||||
.withPush(true)
|
||||
.withGrowAfterOpen(true)
|
||||
.withPositions([
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'bottom',
|
||||
overlayX: 'start',
|
||||
overlayY: 'top',
|
||||
},
|
||||
{
|
||||
originX: 'start',
|
||||
originY: 'top',
|
||||
overlayX: 'start',
|
||||
overlayY: 'bottom',
|
||||
},
|
||||
]);
|
||||
const postContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const postContextMenuPortal = new ComponentPortal(
|
||||
ThreadContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<ThreadContextmenuComponent> =
|
||||
postContextMenuOverlayRef.attach(postContextMenuPortal);
|
||||
ref.instance.thread = event.data as Thread;
|
||||
]);
|
||||
const threadContextMenuOverlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
});
|
||||
const threadContextMenuPortal = new ComponentPortal(
|
||||
ThreadContextmenuComponent
|
||||
);
|
||||
const ref: ComponentRef<ThreadContextmenuComponent> =
|
||||
threadContextMenuOverlayRef.attach(threadContextMenuPortal);
|
||||
ref.instance.thread = row as ThreadRow;
|
||||
|
||||
ref.instance.onThreadSelection = () => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ThreadDialogData = { threadId: event.data.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, { data });
|
||||
};
|
||||
|
||||
ref.instance.onThreadDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
event.api.getSelectedRows().length
|
||||
} post${event.api.getSelectedRows().length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deleteThreads(event.api.getSelectedRows())
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const subscription = merge(
|
||||
fromEvent<MouseEvent>(document, 'click'),
|
||||
fromEvent<MouseEvent>(document, 'contextmenu')
|
||||
)
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
subscription.unsubscribe();
|
||||
postContextMenuOverlayRef?.detach();
|
||||
postContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
},
|
||||
onRowDoubleClicked: (event: RowDoubleClickedEvent<Thread>) => {
|
||||
if (!event.data) {
|
||||
return;
|
||||
}
|
||||
const data: ThreadDialogData = { threadId: event.data.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, { data });
|
||||
},
|
||||
ref.instance.close = () => {
|
||||
threadContextMenuOverlayRef?.detach();
|
||||
threadContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
};
|
||||
|
||||
ref.instance.onThreadSelection = () => {
|
||||
const data: ThreadDialogData = { threadId: row.threadId };
|
||||
this.dialog.open(ThreadSelectionComponent, {
|
||||
data,
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
width: '80vw',
|
||||
height: '80vh',
|
||||
});
|
||||
};
|
||||
|
||||
ref.instance.onThreadDelete = () => {
|
||||
const dialog: MatDialogRef<ConfirmComponent, ConfirmDialogData> =
|
||||
this.dialog.open<ConfirmComponent, ConfirmDialogData>(
|
||||
ConfirmComponent,
|
||||
{
|
||||
data: {
|
||||
message: `Confirm removal of ${
|
||||
this.selection.selected.length
|
||||
} post${this.selection.selected.length > 1 ? 's' : ''}`,
|
||||
confirmCallback: () => {
|
||||
this.applicationEndpoint
|
||||
.deleteThreads(this.selection.selected)
|
||||
.subscribe(() => dialog.close());
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
threadContextMenuOverlayRef
|
||||
.outsidePointerEvents()
|
||||
.pipe(take(1))
|
||||
.subscribe(() => {
|
||||
console.log('click away');
|
||||
threadContextMenuOverlayRef?.detach();
|
||||
threadContextMenuOverlayRef?.dispose();
|
||||
ref.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threads$.subscribe((e: Thread[]) => {
|
||||
const toAdd: Thread[] = [];
|
||||
const toUpdate: Thread[] = [];
|
||||
e.forEach(v => {
|
||||
if (this.agGrid.api.getRowNode(String(v.threadId)) == null) {
|
||||
toAdd.push(v);
|
||||
} else {
|
||||
toUpdate.push(v);
|
||||
}
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ update: toUpdate, add: toAdd });
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
|
||||
const toRemove: any[] = [];
|
||||
e.forEach(v => {
|
||||
const rowNode: IRowNode | undefined = this.agGrid.api.getRowNode(v);
|
||||
if (rowNode != null) {
|
||||
toRemove.push(rowNode.data);
|
||||
}
|
||||
return;
|
||||
});
|
||||
this.agGrid.api.applyTransaction({ remove: toRemove });
|
||||
})
|
||||
);
|
||||
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
|
||||
this.agGrid.api.setRowData([]);
|
||||
})
|
||||
);
|
||||
/** Whether the number of selected elements matches the total number of rows. */
|
||||
isAllSelected() {
|
||||
const numSelected = this.selection.selected.length;
|
||||
const numRows = this.dataSource._dataStream.value.length;
|
||||
return numSelected === numRows;
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
/** Selects all rows if they are not all selected; otherwise clear selection. */
|
||||
toggleAllRows() {
|
||||
if (this.isAllSelected()) {
|
||||
this.selection.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.selection.select(...this.dataSource._dataStream.value);
|
||||
}
|
||||
|
||||
/** The label for the checkbox on the passed row */
|
||||
checkboxLabel(row?: ThreadRow): string {
|
||||
if (!row) {
|
||||
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
|
||||
}
|
||||
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${
|
||||
row.link
|
||||
}`;
|
||||
}
|
||||
|
||||
disableForRows(event: MouseEvent) {
|
||||
@@ -233,8 +221,57 @@ export class ThreadTableComponent implements OnDestroy {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
class ThreadDataSource extends DataSource<Thread> {
|
||||
subscriptions: Subscription[] = [];
|
||||
_dataStream = new BehaviorSubject<Thread[]>([]);
|
||||
|
||||
constructor(private applicationEndpoint: ApplicationEndpointService) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Observable<Thread[]> {
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threads$.subscribe((newThreads: Thread[]) => {
|
||||
newThreads.forEach(thread => {
|
||||
const rowNode = this._dataStream.value.find(
|
||||
d => d.link === thread.link
|
||||
);
|
||||
if (rowNode == null) {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value,
|
||||
new ThreadRow(
|
||||
thread.link,
|
||||
thread.title,
|
||||
thread.threadId,
|
||||
thread.total
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
Object.assign(rowNode, thread);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemove$.subscribe((e: string[]) => {
|
||||
this._dataStream.next([
|
||||
...this._dataStream.value.filter(
|
||||
v => e.find(d => d === v.link) == null
|
||||
),
|
||||
]);
|
||||
})
|
||||
);
|
||||
this.subscriptions.push(
|
||||
this.applicationEndpoint.threadRemoveAll$.subscribe(() => {
|
||||
this._dataStream.next([]);
|
||||
})
|
||||
);
|
||||
return this._dataStream.asObservable();
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.subscriptions.forEach(e => e.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,27 +26,38 @@
|
||||
<mat-divider
|
||||
[vertical]="true"
|
||||
style="height: 20px; flex-grow: 0"></mat-divider>
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="startSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Start selected">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="stopSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Stop selected">
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="removeSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Remove selected">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
<ng-container *ngIf="handsetPortrait$ | async as handsetPortrait">
|
||||
@if (!handsetPortrait.matches) {
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="startSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Start selected">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="stopSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Stop selected">
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[disabled]='disableSelected()'
|
||||
(click)='renameSelectedClick()'
|
||||
mat-icon-button
|
||||
matTooltip='Rename selected'>
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
[disabled]="disableSelected()"
|
||||
(click)="removeSelectedClick()"
|
||||
mat-icon-button
|
||||
matTooltip="Remove selected">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
<mat-divider
|
||||
[vertical]="true"
|
||||
style="height: 20px; flex-grow: 0"></mat-divider>
|
||||
@@ -78,9 +89,9 @@
|
||||
<button mat-icon-button matTooltip="Settings" (click)="settingsClick()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="About">
|
||||
<mat-icon>info</mat-icon>
|
||||
</button>
|
||||
<!-- <button mat-icon-button matTooltip="About">-->
|
||||
<!-- <mat-icon>info</mat-icon>-->
|
||||
<!-- </button>-->
|
||||
</div>
|
||||
</div>
|
||||
</mat-toolbar-row>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ScanComponent } from '../scan/scan.component';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toolbar',
|
||||
@@ -48,6 +49,9 @@ export class ToolbarComponent {
|
||||
@Output()
|
||||
deleteSelected = new EventEmitter<void>();
|
||||
|
||||
@Output()
|
||||
renameSelected = new EventEmitter<void>();
|
||||
|
||||
@Output()
|
||||
clearDownload = new EventEmitter<void>();
|
||||
|
||||
@@ -69,11 +73,21 @@ export class ToolbarComponent {
|
||||
@Input({ required: true })
|
||||
disableSelected!: Signal<boolean>;
|
||||
|
||||
constructor(public dialog: MatDialog) {}
|
||||
handsetPortrait$ = this.breakpointObserver.observe(
|
||||
Breakpoints.HandsetPortrait
|
||||
);
|
||||
|
||||
constructor(
|
||||
public dialog: MatDialog,
|
||||
private breakpointObserver: BreakpointObserver
|
||||
) {}
|
||||
|
||||
openScanDialog() {
|
||||
this.dialog
|
||||
.open<ScanComponent, never, string>(ScanComponent)
|
||||
.open<ScanComponent, never, string>(ScanComponent, {
|
||||
maxWidth: '100vw',
|
||||
maxHeight: '100vh',
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe(v => {
|
||||
if (v) {
|
||||
@@ -106,6 +120,10 @@ export class ToolbarComponent {
|
||||
this.deleteSelected.next();
|
||||
}
|
||||
|
||||
renameSelectedClick() {
|
||||
this.renameSelected.next();
|
||||
}
|
||||
|
||||
clearDownloadsClick() {
|
||||
this.clearDownload.next();
|
||||
}
|
||||
|
||||
@@ -19,3 +19,36 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function statusIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'DOWNLOADING':
|
||||
return 'download_for_offline';
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'FINISHED':
|
||||
return 'check_circle';
|
||||
case 'ERROR':
|
||||
return 'error';
|
||||
case 'STOPPED':
|
||||
return 'pause_circle';
|
||||
default:
|
||||
return 'question_mark';
|
||||
}
|
||||
}
|
||||
|
||||
export function progress(done: number, total: number): number {
|
||||
return done === 0 || total === 0 ? 0 : (done / total) * 100;
|
||||
}
|
||||
|
||||
export function totalFormatter(
|
||||
done: number,
|
||||
total: number,
|
||||
downloaded: number
|
||||
): string {
|
||||
return `${done}/${total} (${formatBytes(downloaded)})`;
|
||||
}
|
||||
|
||||
export function isDisplayed(columns: string[], column: string): boolean {
|
||||
return columns.findIndex(v => v === column) > -1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@import 'ag-grid-community/styles/ag-grid.css';
|
||||
@import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||||
@use '@angular/material' as mat;
|
||||
@use './app/download-table/download-table.component-theme' as downloadTable;
|
||||
|
||||
@include mat.core();
|
||||
|
||||
$my-primary: mat.define-palette(mat.$indigo-palette, 500);
|
||||
$my-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400);
|
||||
|
||||
$my-theme: mat.define-light-theme((
|
||||
color: (
|
||||
primary: $my-primary,
|
||||
accent: $my-accent,
|
||||
),
|
||||
typography: mat.define-typography-config(
|
||||
$font-family: roboto,
|
||||
)
|
||||
));
|
||||
|
||||
@include mat.all-component-themes($my-theme);
|
||||
@include downloadTable.theme($my-theme);
|
||||
|
||||
html,
|
||||
body {
|
||||
@@ -17,7 +35,7 @@ body {
|
||||
}
|
||||
|
||||
#tab-group {
|
||||
height: calc(100% - 64px);
|
||||
height: calc(100% - 84px);
|
||||
}
|
||||
|
||||
.ag-theme-alpine {
|
||||
@@ -53,7 +71,7 @@ body {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
animation: sk-chase-dot 2.0s infinite ease-in-out both;
|
||||
animation: sk-chase-dot 2s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.sk-chase-dot:before {
|
||||
@@ -63,7 +81,7 @@ body {
|
||||
height: 25%;
|
||||
background-color: #fff;
|
||||
border-radius: 100%;
|
||||
animation: sk-chase-dot-before 2.0s infinite ease-in-out both;
|
||||
animation: sk-chase-dot-before 2s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(1) {
|
||||
@@ -71,7 +89,7 @@ body {
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(2) {
|
||||
animation-delay: -1.0s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(3) {
|
||||
@@ -95,7 +113,7 @@ body {
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(2):before {
|
||||
animation-delay: -1.0s;
|
||||
animation-delay: -1s;
|
||||
}
|
||||
|
||||
.sk-chase-dot:nth-child(3):before {
|
||||
@@ -121,7 +139,8 @@ body {
|
||||
}
|
||||
|
||||
@keyframes sk-chase-dot {
|
||||
80%, 100% {
|
||||
80%,
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -130,17 +149,19 @@ body {
|
||||
50% {
|
||||
transform: scale(0.4);
|
||||
}
|
||||
100%, 0% {
|
||||
transform: scale(1.0);
|
||||
100%,
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
|
||||
.truncate-cell {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 1px;
|
||||
white-space: nowrap;
|
||||
padding: 2px 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -10,7 +10,7 @@
|
||||
<parent>
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-project</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
@@ -37,12 +37,12 @@
|
||||
<dependency>
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-core</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>me.vripper</groupId>
|
||||
<artifactId>vripper-web-ui</artifactId>
|
||||
<version>5.0.0</version>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ package me.vripper.web.restendpoints
|
||||
import me.vripper.model.PostItem
|
||||
import me.vripper.model.ThreadPostId
|
||||
import me.vripper.services.AppEndpointService
|
||||
import me.vripper.web.restendpoints.domain.*
|
||||
import me.vripper.web.restendpoints.domain.RenameRequest
|
||||
import me.vripper.web.restendpoints.domain.ScanRequest
|
||||
import me.vripper.web.restendpoints.exceptions.BadRequestException
|
||||
import me.vripper.web.restendpoints.exceptions.ServerErrorException
|
||||
import org.koin.core.component.KoinComponent
|
||||
@@ -98,4 +99,10 @@ class PostRestEndpoint : KoinComponent {
|
||||
fun threadClear() {
|
||||
appEndpointService.threadClear()
|
||||
}
|
||||
|
||||
@PostMapping("/post/rename")
|
||||
@ResponseStatus(value = HttpStatus.OK)
|
||||
fun rename(@RequestBody renameRequest: RenameRequest) {
|
||||
appEndpointService.rename(renameRequest.postId, renameRequest.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package me.vripper.web.restendpoints.domain
|
||||
|
||||
data class RenameRequest(val postId: Long, val name: String)
|
||||
@@ -17,23 +17,14 @@ class DataBroadcast(
|
||||
|
||||
@PostConstruct
|
||||
private fun run() {
|
||||
eventBus.events.ofType(PostEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { events ->
|
||||
|
||||
events.map { it.delete }.flatten().also {
|
||||
if (it.isNotEmpty()) {
|
||||
template.convertAndSend("/topic/posts/deleted", it)
|
||||
}
|
||||
}
|
||||
|
||||
events.map { it.add }.flatten().also {
|
||||
if (it.isNotEmpty()) {
|
||||
template.convertAndSend("/topic/posts/new", it)
|
||||
}
|
||||
}
|
||||
|
||||
events.map { it.update }.flatten().reversed().distinct().also {
|
||||
template.convertAndSend("/topic/posts/updated", it)
|
||||
}
|
||||
eventBus.events.ofType(PostCreateEvent::class.java).subscribe { events ->
|
||||
template.convertAndSend("/topic/posts/new", events.posts)
|
||||
}
|
||||
eventBus.events.ofType(PostUpdateEvent::class.java).subscribe { events ->
|
||||
template.convertAndSend("/topic/posts/updated", events.posts)
|
||||
}
|
||||
eventBus.events.ofType(PostDeleteEvent::class.java).subscribe { events ->
|
||||
template.convertAndSend("/topic/posts/deleted", events.postIds)
|
||||
}
|
||||
|
||||
eventBus.events.ofType(QueueStateEvent::class.java).subscribe {
|
||||
@@ -56,45 +47,39 @@ class DataBroadcast(
|
||||
template.convertAndSend("/topic/loading", it.loading)
|
||||
}
|
||||
|
||||
eventBus.events.ofType(ImageEvent::class.java).buffer(Duration.ofMillis(125)).subscribe { events ->
|
||||
events.map { it.images }.flatten().reversed().distinct().groupBy { it.postId }
|
||||
.forEach { template.convertAndSend("/topic/images/${it.key}", it.value) }
|
||||
eventBus.events.ofType(ImageEvent::class.java).buffer(Duration.ofMillis(500)).subscribe { events ->
|
||||
events.reversed().map { it.images }.flatten().distinct().groupBy { it.postId }
|
||||
.forEach {
|
||||
template.convertAndSend("/topic/images/${it.key}", it.value)
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.events.ofType(ThreadCreateEvent::class.java).map { it.thread }.distinct()
|
||||
.buffer(Duration.ofMillis(125)).subscribe {
|
||||
template.convertAndSend("/topic/threads", it)
|
||||
eventBus.events.ofType(ThreadCreateEvent::class.java).map { listOf(it.thread) }
|
||||
.subscribe { events ->
|
||||
template.convertAndSend("/topic/threads", events)
|
||||
}
|
||||
|
||||
eventBus.events.ofType(ThreadDeleteEvent::class.java).map { it.threadId }.distinct()
|
||||
.buffer(Duration.ofMillis(125)).subscribe {
|
||||
eventBus.events.ofType(ThreadDeleteEvent::class.java).map { listOf(it.threadId) }
|
||||
.subscribe {
|
||||
template.convertAndSend("/topic/threads/deleted", it)
|
||||
}
|
||||
|
||||
eventBus.events.ofType(ThreadClearEvent::class.java).subscribe {
|
||||
template.convertAndSend("/topic/threads/deletedAll", listOf(true))
|
||||
eventBus.events.ofType(ThreadClearEvent::class.java).map { listOf(true) }.subscribe {
|
||||
template.convertAndSend("/topic/threads/deletedAll", it)
|
||||
}
|
||||
|
||||
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }.buffer(Duration.ofMillis(125))
|
||||
eventBus.events.ofType(LogCreateEvent::class.java).map { it.logEntry }
|
||||
.subscribe { logCreateEvent ->
|
||||
logCreateEvent.distinct().also {
|
||||
if (it.isNotEmpty()) {
|
||||
template.convertAndSend("/topic/logs/new", it)
|
||||
}
|
||||
}
|
||||
template.convertAndSend("/topic/logs/new", listOf(logCreateEvent))
|
||||
}
|
||||
|
||||
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }.buffer(Duration.ofMillis(125))
|
||||
eventBus.events.ofType(LogUpdateEvent::class.java).map { it.logEntry }
|
||||
.subscribe { logUpdateEvent ->
|
||||
logUpdateEvent.distinct().also {
|
||||
if (it.isNotEmpty()) {
|
||||
template.convertAndSend("/topic/logs/updated", it)
|
||||
}
|
||||
}
|
||||
template.convertAndSend("/topic/logs/updated", listOf(logUpdateEvent))
|
||||
}
|
||||
|
||||
eventBus.events.ofType(LogDeleteEvent::class.java).subscribe {
|
||||
template.convertAndSend("/topic/logs/deleted", it.deleted)
|
||||
template.convertAndSend("/topic/logs/deleted", listOf(it.deleted))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user