diff --git a/assets/css/dashboard.css b/assets/css/dashboard.css
new file mode 100644
index 0000000..d2667ea
--- /dev/null
+++ b/assets/css/dashboard.css
@@ -0,0 +1,110 @@
+/**
+ * Piperless Dashboard Widget Styles
+ *
+ * Card-based layout for the WordPress admin dashboard widget
+ * showing audio storage stats at a glance.
+ *
+ * @package Piperless
+ */
+
+/* ── Dashboard container ───────────────────────────────────────────────── */
+.piperless-dashboard {
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.piperless-dash-empty {
+ color: #757575;
+ font-style: italic;
+ margin: 0;
+}
+
+/* ── Stat cards grid ───────────────────────────────────────────────────── */
+.piperless-dash-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ margin-bottom: 14px;
+}
+
+.piperless-dash-card {
+ background: #f8f9fa;
+ border: 1px solid #e5e7eb;
+ border-left: 4px solid var(--card-accent);
+ border-radius: 6px;
+ padding: 12px;
+ text-align: center;
+}
+
+.piperless-dash-card-value {
+ font-size: 26px;
+ font-weight: 700;
+ line-height: 1.2;
+ color: #1d2327;
+}
+
+.piperless-dash-card-label {
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #646970;
+ margin: 2px 0;
+}
+
+.piperless-dash-card-sub {
+ font-size: 12px;
+ color: #757575;
+ margin-top: 2px;
+}
+
+/* ── Info rows ─────────────────────────────────────────────────────────── */
+.piperless-dash-info {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.piperless-dash-info-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ background: #f8f9fa;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+}
+
+.piperless-dash-info-icon {
+ font-size: 18px;
+ flex-shrink: 0;
+ width: 24px;
+ text-align: center;
+}
+
+.piperless-dash-info-text {
+ flex: 1;
+ color: #1d2327;
+}
+
+.piperless-dash-info-link {
+ margin-left: auto;
+ font-size: 12px;
+ text-decoration: none;
+ color: #2271b1;
+ white-space: nowrap;
+}
+
+.piperless-dash-info-link:hover {
+ color: #135e96;
+ text-decoration: underline;
+}
+
+/* ── Color highlights per info row ─────────────────────────────────────── */
+.piperless-dash-info-posts {
+ border-left: 4px solid #dba617;
+}
+
+.piperless-dash-info-storage {
+ border-left: 4px solid #2271b1;
+}
diff --git a/includes/class-dashboard.php b/includes/class-dashboard.php
new file mode 100644
index 0000000..6e316de
--- /dev/null
+++ b/includes/class-dashboard.php
@@ -0,0 +1,295 @@
+logger = $logger;
+ $this->cache = $cache;
+ }
+
+ /**
+ * Register hooks.
+ */
+ public function init(): void {
+ add_action( 'wp_dashboard_setup', [ $this, 'register_widget' ] );
+ add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_styles' ] );
+ }
+
+ /**
+ * Register the dashboard widget.
+ */
+ public function register_widget(): void {
+ wp_add_dashboard_widget(
+ $this->widget_id,
+ __( 'Piperless — Audio Storage', 'piperless' ),
+ [ $this, 'render' ]
+ );
+ }
+
+ /**
+ * Render the dashboard widget.
+ */
+ public function render(): void {
+ $file_stats = $this->get_file_stats();
+ $without = $this->get_posts_without_audio();
+ $disk_free = $this->get_disk_free();
+ $total_files = ( $file_stats['mp3']['count'] ?? 0 )
+ + ( $file_stats['wav']['count'] ?? 0 )
+ + ( $file_stats['opus']['count'] ?? 0 );
+ ?>
+
+
+
+
+
+
+ render_file_card(
+ __( 'MP3', 'piperless' ),
+ $file_stats['mp3'] ?? [ 'count' => 0, 'bytes' => 0 ],
+ '#008a20'
+ ); ?>
+ render_file_card(
+ __( 'Opus', 'piperless' ),
+ $file_stats['opus'] ?? [ 'count' => 0, 'bytes' => 0 ],
+ '#1565c0'
+ ); ?>
+ render_file_card(
+ __( 'WAV', 'piperless' ),
+ $file_stats['wav'] ?? [ 'count' => 0, 'bytes' => 0 ],
+ '#f56e28'
+ ); ?>
+
+
+
+
+
+
✎
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+ ⌨
+
+
+
+
+
+
+ int, 'bytes' => int].
+ * @param string $color Accent color hex.
+ */
+ private function render_file_card( string $label, array $stats, string $color ): void {
+ $count = $stats['count'] ?? 0;
+ $size = $this->human_size( $stats['bytes'] ?? 0 );
+ ?>
+
+
+ */
+ private function get_file_stats(): array {
+ $cache_dir = $this->cache->dir();
+ $stats = [
+ 'mp3' => [ 'count' => 0, 'bytes' => 0 ],
+ 'wav' => [ 'count' => 0, 'bytes' => 0 ],
+ 'opus' => [ 'count' => 0, 'bytes' => 0 ],
+ ];
+
+ if ( ! is_dir( $cache_dir ) ) {
+ return $stats;
+ }
+
+ foreach ( [ 'mp3', 'wav', 'opus' ] as $ext ) {
+ $files = (array) glob( $cache_dir . '/*.' . $ext );
+ foreach ( $files as $file ) {
+ $size = @filesize( $file );
+ if ( false !== $size ) {
+ $stats[ $ext ]['count']++;
+ $stats[ $ext ]['bytes'] += $size;
+ }
+ }
+ }
+
+ return $stats;
+ }
+
+ /**
+ * Count published posts / pages without an audio transcript.
+ *
+ * @return int
+ */
+ private function get_posts_without_audio(): int {
+ global $wpdb;
+
+ $types = $this->supported_post_types();
+ $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
+ $count = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(*)
+ FROM {$wpdb->posts} p
+ WHERE p.post_type IN ( {$placeholders} )
+ AND p.post_status = 'publish'
+ AND NOT EXISTS (
+ SELECT 1 FROM {$wpdb->postmeta} pm
+ WHERE pm.post_id = p.ID
+ AND pm.meta_key = '_piperless_audio_url'
+ AND pm.meta_value != ''
+ )",
+ ...$types
+ )
+ );
+
+ return (int) $count;
+ }
+
+ /**
+ * Get human-readable free disk space on the uploads directory volume.
+ *
+ * @return string e.g. "12.3 GB"
+ */
+ private function get_disk_free(): string {
+ $dir = $this->cache->dir();
+
+ // Walk up until we find a directory that exists.
+ while ( ! is_dir( $dir ) && '/' !== $dir ) {
+ $dir = dirname( $dir );
+ }
+
+ $free = @disk_free_space( $dir );
+
+ if ( false === $free || null === $free ) {
+ return __( 'Unavailable', 'piperless' );
+ }
+
+ return $this->human_size( $free );
+ }
+
+ /**
+ * Format bytes as a human-readable string.
+ *
+ * @param int $bytes Raw byte count.
+ * @return string e.g. "24.5 MB"
+ */
+ private function human_size( int $bytes ): string {
+ if ( 0 === $bytes ) {
+ return '0 B';
+ }
+
+ $units = [ 'B', 'KB', 'MB', 'GB', 'TB' ];
+ $exp = (int) floor( log( $bytes, 1024 ) );
+ $exp = min( $exp, count( $units ) - 1 );
+
+ $val = $bytes / ( 1024 ** $exp );
+
+ return round( $val, 1 ) . ' ' . $units[ $exp ];
+ }
+
+ /**
+ * Enqueue dashboard-specific styles on the dashboard page only.
+ *
+ * @param string $hook_suffix Current admin page hook.
+ */
+ public function enqueue_styles( string $hook_suffix ): void {
+ if ( 'index.php' !== $hook_suffix ) {
+ return;
+ }
+
+ wp_enqueue_style(
+ 'piperless-dashboard',
+ PIPERLESS_PLUGIN_URL . 'assets/css/dashboard.css',
+ [],
+ PIPERLESS_VERSION
+ );
+ }
+
+ /**
+ * Supported post types for audio transcripts.
+ *
+ * @return string[]
+ */
+ private function supported_post_types(): array {
+ $types = apply_filters( 'piperless_post_types', [ 'post', 'page' ] );
+ return is_array( $types ) ? $types : [ 'post' ];
+ }
+}
diff --git a/includes/class-plugin.php b/includes/class-plugin.php
index 0a64e39..43ad65a 100644
--- a/includes/class-plugin.php
+++ b/includes/class-plugin.php
@@ -57,6 +57,9 @@ class Plugin {
/** @var Gutenberg */
private Gutenberg $gutenberg;
+ /** @var Dashboard */
+ private Dashboard $dashboard;
+
/**
* Private constructor — use ::instance().
*/
@@ -68,6 +71,7 @@ class Plugin {
$this->transcriber = new Transcriber( $this->logger, $this->piper, $this->cache_manager );
$this->player = new Player( $this->logger );
$this->gutenberg = new Gutenberg( $this->logger, $this->transcriber, $this->cache_manager );
+ $this->dashboard = new Dashboard( $this->logger, $this->cache_manager );
}
/**
@@ -89,6 +93,7 @@ class Plugin {
$this->settings->init();
$this->gutenberg->init();
$this->player->init();
+ $this->dashboard->init();
// Auto-generate on publish.
add_action( 'transition_post_status', [ $this, 'maybe_auto_generate' ], 10, 3 );