Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 118 additions & 2 deletions assets/css/content-graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,37 @@

.desktop-mode-content-graph__toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-direction: column;
gap: 8px;
padding: 10px 14px;
border-bottom: 1px solid var( --wpd-border, #e2e6ec );
background: var( --wpd-surface-2, #fff );
}

.desktop-mode-content-graph__header-row {
display: flex;
align-items: center;
gap: 12px;
}

.desktop-mode-content-graph__mode-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}

.desktop-mode-content-graph__visible-count {
font-size: 12px;
color: var( --wpd-text-muted, #5a6473 );
margin-left: auto;
white-space: nowrap;
}

.desktop-mode-content-graph__range {
min-width: 140px;
}

.desktop-mode-content-graph__filters {
display: flex;
align-items: center;
Expand Down Expand Up @@ -1030,3 +1053,96 @@
text-overflow: ellipsis;
white-space: nowrap;
}

/* ----- Galaxy view ----- */

/*
* Galaxy mode flips the stage canvas to a starfield aesthetic: dark
* background, additive-blended sprite dots, per-cluster nebula glow,
* DOM-overlay cluster labels, and a small explanatory legend along
* the bottom edge. The `.is-galaxy` modifier is added by
* GalaxyScene.mount() so the same `.desktop-mode-content-graph__stage`
* shell can host either renderer.
*/
.desktop-mode-content-graph__stage.is-galaxy {
background: #0b0d18;
color: #e4e7ef;
}

.desktop-mode-content-graph__galaxy-labels {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
overflow: hidden;
}

.desktop-mode-content-graph__galaxy-label {
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
color: #f7f9ff;
font-family: var( --wpd-font, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif );
text-shadow: 0 1px 6px rgba( 0, 0, 0, 0.85 );
pointer-events: none;
will-change: transform;
}

.desktop-mode-content-graph__galaxy-label-name {
font-size: 14px;
font-weight: 600;
letter-spacing: 0.2px;
}

.desktop-mode-content-graph__galaxy-label-count {
font-size: 11px;
opacity: 0.7;
}

.desktop-mode-content-graph__galaxy-tooltip {
position: absolute;
top: 0;
left: 0;
padding: 5px 9px;
border-radius: 6px;
background: rgba( 14, 18, 32, 0.92 );
color: #f7f9ff;
font-size: 12px;
pointer-events: none;
z-index: 3;
box-shadow: 0 8px 20px rgba( 0, 0, 0, 0.4 );
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
will-change: transform;
}

.desktop-mode-content-graph__galaxy-legend {
position: absolute;
left: 14px;
right: 14px;
bottom: 10px;
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
font-size: 11px;
color: rgba( 231, 236, 248, 0.65 );
pointer-events: none;
z-index: 2;
}

.desktop-mode-content-graph__galaxy-legend strong {
color: rgba( 231, 236, 248, 0.95 );
font-weight: 600;
margin-right: 4px;
}

.desktop-mode-content-graph__galaxy-legend-spacer {
flex: 1 1 auto;
}
12 changes: 12 additions & 0 deletions docs/javascript-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -4440,6 +4440,18 @@ heartbeat subscription above.

---

## User meta keys

Per-user preferences the framework persists over REST. These are part
of the JS contract because the shell reads/writes them from bundles:

| Meta key | Values | Written via | Status |
|---|---|---|---|
| `desktop_mode_content_graph_view` | `'graph'` \| `'galaxy'` (default `'graph'`) | `POST /wp/v2/users/<id>` with `{ meta: { … } }` when the Content Graph view toggle flips; read server-side into the window config (`lastView`). Registered with `show_in_rest`; writes require `edit_posts`. | Internal *(since 0.9.2)* |
| `dockRailRenderer` | any `sanitize_key()`-clean renderer id | `/wp-json/desktop-mode/v1/os-settings` (see [`registerDockRailRenderer`](#registerdockrailrenderer-def---stable-since-0180)) | Stable *(since 0.18.0)* |

---

## See also

- [Hooks Reference](./hooks-reference.md) — the PHP side of the API.
Expand Down
83 changes: 75 additions & 8 deletions includes/content-graph/graph-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
// previous schema (e.g. missing per-node `contributor_ids`) and
// surface as runtime errors on the client. Each bump is a one-time
// cache miss for every site that updates the plugin.
const DESKTOP_MODE_CONTENT_GRAPH_TRANSIENT_PREFIX = 'desktop_mode_cg2_';
const DESKTOP_MODE_CONTENT_GRAPH_TRANSIENT_PREFIX = 'desktop_mode_cg3_';
const DESKTOP_MODE_CONTENT_GRAPH_TRANSIENT_TTL = 6 * HOUR_IN_SECONDS;

/**
Expand All @@ -47,7 +47,8 @@
* slug: string, edit_url: string,
* author_id: int, contributor_ids: int[],
* year: int, year_month: string,
* category_ids: int[], tag_ids: int[]
* category_ids: int[], tag_ids: int[],
* comment_count: int, word_count: int, modified_ts: int
* }>,
* edges: array<int, array{ from: int, to: int }>,
* groups: array{
Expand Down Expand Up @@ -138,6 +139,27 @@ static function ( $cid ) use ( $author_id ) {
);
}

// Word count for the Galaxy view's brightness encoding. Strip
// shortcodes + tags first so a 200-word post drowning in HTML
// markup doesn't read as a 2000-word post. `str_word_count` is
// locale-aware enough for our needs (we only use it as a relative
// brightness signal, not for editorial display).
$plain = wp_strip_all_tags( strip_shortcodes( (string) $row->post_content ), true );
$word_count = $plain === '' ? 0 : (int) str_word_count( $plain );
// Modified-time as unix ts (GMT). The Galaxy view's "Recent" tab
// filters on `now - 30 days`; comparing seconds is faster + safer
// across the wire than parsing a date string client-side.
// Never-updated drafts carry a zero-date `post_modified_gmt`
// (WordPress only stamps the GMT columns on update), so fall
// back to the local `post_date` — for a fresh draft the two
// describe the same instant. Without this, brand-new drafts
// never twinkle and never qualify for the "Recent" tab.
$modified_gmt = isset( $row->post_modified_gmt ) ? (string) $row->post_modified_gmt : '';
if ( '' === $modified_gmt || '0000-00-00 00:00:00' === $modified_gmt ) {
$modified_gmt = (string) get_gmt_from_date( (string) $row->post_date );
}
$modified_ts = (int) mysql2date( 'U', $modified_gmt . ' UTC', false );

$node = array(
'id' => $id,
'type' => (string) $row->post_type,
Expand All @@ -151,6 +173,9 @@ static function ( $cid ) use ( $author_id ) {
'year_month' => $year_month,
'category_ids' => $post_cats,
'tag_ids' => $post_tags,
'comment_count' => (int) ( isset( $row->comment_count ) ? $row->comment_count : 0 ),
'word_count' => $word_count,
'modified_ts' => $modified_ts,
);
$nodes[] = $node;
$nodes_by_id[ $id ] = true;
Expand Down Expand Up @@ -260,21 +285,58 @@ function desktop_mode_content_graph_normalize_types( array $types ) {
function desktop_mode_content_graph_cache_key( array $types ) {
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
// Hash MUST match the set of rows fetched in
// `desktop_mode_content_graph_fetch_rows()` — including this user's
// own drafts when logged in, so cache invalidates on draft edits too.
// The shared status-clause helper keeps the two row sets in lockstep.
list( $status_sql, $draft_args ) = desktop_mode_content_graph_status_clause();
$current_user_id = (int) get_current_user_id();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$hash = (string) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $status_sql is composed of literals in desktop_mode_content_graph_status_clause().
"SELECT MD5( GROUP_CONCAT( CONCAT( ID, ':', post_modified_gmt ) ORDER BY ID ) )
FROM {$wpdb->posts}
WHERE post_status IN ( 'publish', 'private' )
WHERE {$status_sql}
AND post_type IN ( {$placeholders} )",
$types
array_merge( $draft_args, $types )
)
);
// phpcs:enable
if ( '' === $hash || null === $hash ) {
$hash = 'empty';
}
return DESKTOP_MODE_CONTENT_GRAPH_TRANSIENT_PREFIX . substr( md5( implode( ',', $types ) . '|' . $hash ), 0, 24 );
// Bucket the cache key by user so two editors see independently-built
// payloads (their own drafts won't bleed into each other's view).
return DESKTOP_MODE_CONTENT_GRAPH_TRANSIENT_PREFIX . substr(
md5( implode( ',', $types ) . '|u' . $current_user_id . '|' . $hash ),
0,
24
);
}

/**
* Post-status WHERE clause shared by the cache-key hash and the row
* fetch. Published + private posts for everyone, plus the current
* user's own drafts when logged in (other users' drafts must never
* surface). The two call sites MUST select the same row set — the
* cache key hashes exactly the rows the fetch would return — which is
* why the clause lives in one place.
*
* @since 0.9.2
*
* @return array{0: string, 1: int[]} SQL fragment (may contain a `%d`
* placeholder) + its prepare() args.
*/
function desktop_mode_content_graph_status_clause() {
$current_user_id = (int) get_current_user_id();
if ( $current_user_id > 0 ) {
return array(
"( post_status IN ( 'publish', 'private' ) OR ( post_status = 'draft' AND post_author = %d ) )",
array( $current_user_id ),
);
}
return array( "post_status IN ( 'publish', 'private' )", array() );
}

/**
Expand All @@ -290,15 +352,20 @@ function desktop_mode_content_graph_cache_key( array $types ) {
function desktop_mode_content_graph_fetch_rows( array $types ) {
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
// Include the current user's own drafts so the Galaxy view's
// "Drafts" tab has something to surface without exposing other
// users' unpublished work. Other-user drafts stay invisible.
list( $status_sql, $draft_args ) = desktop_mode_content_graph_status_clause();
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_type, post_status, post_title, post_name, post_content, post_author, post_date
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $status_sql is composed of literals in desktop_mode_content_graph_status_clause().
"SELECT ID, post_type, post_status, post_title, post_name, post_content, post_author, post_date, post_modified_gmt, comment_count
FROM {$wpdb->posts}
WHERE post_status IN ( 'publish', 'private' )
WHERE {$status_sql}
AND post_type IN ( {$placeholders} )
ORDER BY post_date DESC",
$types
array_merge( $draft_args, $types )
)
);
// phpcs:enable
Expand Down
39 changes: 39 additions & 0 deletions includes/content-graph/window.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,16 @@ function desktop_mode_content_graph_register_window() {
'editCommentUrl' => esc_url_raw( admin_url( 'comment.php' ) ),
'mediaUrl' => esc_url_raw( admin_url( 'upload.php' ) ),
'postTypes' => desktop_mode_content_graph_post_types(),
// Last-chosen view mode persisted via user meta. Read here so
// the bundle can mount the right scene without an extra round
// trip on every window open; saved client-side via REST when
// the user toggles the segmented control.
'lastView' => (string) get_user_meta(
get_current_user_id(),
'desktop_mode_content_graph_view',
true
) ?: 'graph',
'currentUserId' => (int) get_current_user_id(),
),
);

Expand Down Expand Up @@ -205,3 +215,32 @@ function desktop_mode_content_graph_enqueue_styles() {
wp_enqueue_style( 'desktop-mode-content-graph' );
}
add_action( 'admin_enqueue_scripts', 'desktop_mode_content_graph_enqueue_styles', 30 );

/**
* Register the per-user view preference (`'graph'` or `'galaxy'`).
* Exposed over REST so the JS toolbar can `POST /wp/v2/users/me` to
* persist the user's last choice across sessions and devices.
*
* @since 0.9.2
*/
function desktop_mode_content_graph_register_view_meta() {
register_meta(
'user',
'desktop_mode_content_graph_view',
array(
'type' => 'string',
'single' => true,
'default' => 'graph',
'show_in_rest' => true,
'sanitize_callback' => static function ( $value ) {
return in_array( (string) $value, array( 'graph', 'galaxy' ), true )
? (string) $value
: 'graph';
},
'auth_callback' => static function () {
return current_user_can( 'edit_posts' );
},
)
);
}
add_action( 'init', 'desktop_mode_content_graph_register_view_meta' );
Loading