diff --git a/blueprints/my-wordpress/recipes.json b/blueprints/my-wordpress/recipes.json index 4132709d..397df8aa 100644 --- a/blueprints/my-wordpress/recipes.json +++ b/blueprints/my-wordpress/recipes.json @@ -355,5 +355,45 @@ "description": "Pick a reminder interval per contact (a month, a quarter, a year). When the date comes around, you'll see them on the dashboard with a nudge to reach out." } ] + }, + "own-your-reading-life": { + "title": "Own Your Reading Life", + "tagline": "Your shelves, your ratings, your quotes โ on your own site", + "description": "Book platforms know everything you read, rate, and highlight โ and they use it to sell to you. This workflow rebuilds the whole experience on your own WordPress: shelves with a yearly goal, a notebook of quotes and thoughts per book, and a reader that follows your favorite authors โ private by default.", + "icon": "๐", + "gradient": "linear-gradient(135deg, #7c3aed 0%, #db2777 100%)", + "steps": [ + { + "type": "app", + "path": "apps/readforge.json", + "title": "Install ReadForge", + "description": "Three shelves โ Want to Read, Reading, Finished โ with drag-and-drop, progress bars, star ratings, and a yearly reading goal that counts every book you finish." + }, + { + "type": "note", + "title": "Shelve the books around you", + "description": "Start with the pile on your nightstand and the three books everyone keeps recommending. Set a yearly goal you'd be happy with โ the counter only ever motivates, never shames.", + "url": "/books/", + "url_label": "Open your shelves" + }, + { + "type": "app", + "path": "apps/memex.json", + "title": "Keep quotes and thoughts in Memex", + "description": "One note per book for the passages worth keeping and what you thought when you read them. Backlinks connect books to the ideas and people they remind you of โ your highlights stop belonging to a platform." + }, + { + "type": "app", + "path": "apps/rss-reader.json", + "title": "Follow your favorite authors", + "optional": true, + "description": "Subscribe to author blogs and book-review sites in your own reader. New-release news comes to you without an algorithm deciding what you see." + }, + { + "type": "note", + "title": "Log progress when you put the book down", + "description": "Click the progress bar when you stop reading โ it takes two seconds and keeps the Reading shelf honest. When you finish, drag the book to Finished, rate it, and jot the one-line verdict in its Memex note." + } + ] } } diff --git a/blueprints/readforge/blueprint.json b/blueprints/readforge/blueprint.json new file mode 100644 index 00000000..6335d4db --- /dev/null +++ b/blueprints/readforge/blueprint.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://playground.wordpress.net/blueprint-schema.json", + "meta": { + "title": "ReadForge", + "description": "A private reading tracker. Shelves for want-to-read, reading, and finished โ with progress bars, star ratings, and a yearly goal. Your books, your data.", + "author": "muryamsultana", + "categories": [ + "Apps", + "Productivity" + ] + }, + "landingPage": "/books/", + "preferredVersions": { + "php": "8.2", + "wp": "latest" + }, + "login": true, + "steps": [ + { + "step": "mkdir", + "path": "/wordpress/wp-content/plugins/readforge" + }, + { + "step": "writeFile", + "path": "/wordpress/wp-content/plugins/readforge/readforge.php", + "data": { + "resource": "bundled", + "path": "/plugin/readforge.php" + } + }, + { + "step": "activatePlugin", + "pluginPath": "readforge/readforge.php" + }, + { + "step": "setSiteOptions", + "options": { + "blogname": "ReadForge", + "blogdescription": "Your shelves, your ratings, your data" + } + } + ] +} diff --git a/blueprints/readforge/plugin/readforge.php b/blueprints/readforge/plugin/readforge.php new file mode 100644 index 00000000..865b9074 --- /dev/null +++ b/blueprints/readforge/plugin/readforge.php @@ -0,0 +1,449 @@ + array( 'label' => __( 'Want to Read', 'readforge' ), 'color' => '#3b82f6' ), + 'reading' => array( 'label' => __( 'Reading', 'readforge' ), 'color' => '#f59e0b' ), + 'finished' => array( 'label' => __( 'Finished', 'readforge' ), 'color' => '#22c55e' ), + ); + } + + public static function init() { + add_action( 'init', array( __CLASS__, 'register_cpt' ) ); + add_action( 'template_redirect', array( __CLASS__, 'maybe_render_app' ) ); + add_action( 'wp_ajax_rf_add', array( __CLASS__, 'ajax_add' ) ); + add_action( 'wp_ajax_rf_move', array( __CLASS__, 'ajax_move' ) ); + add_action( 'wp_ajax_rf_progress', array( __CLASS__, 'ajax_progress' ) ); + add_action( 'wp_ajax_rf_rate', array( __CLASS__, 'ajax_rate' ) ); + add_action( 'wp_ajax_rf_goal', array( __CLASS__, 'ajax_goal' ) ); + add_action( 'wp_ajax_rf_delete', array( __CLASS__, 'ajax_delete' ) ); + add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) ); + register_activation_hook( __FILE__, array( __CLASS__, 'activate' ) ); + } + + public static function register_cpt() { + register_post_type( + self::CPT, + array( + 'labels' => array( + 'name' => __( 'Books', 'readforge' ), + 'singular_name' => __( 'Book', 'readforge' ), + ), + 'public' => false, + 'show_ui' => false, + 'supports' => array( 'title' ), + ) + ); + } + + public static function activate() { + self::register_cpt(); + if ( ! get_page_by_path( self::PAGE_SLUG ) ) { + wp_insert_post( + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => __( 'Books', 'readforge' ), + 'post_name' => self::PAGE_SLUG, + 'post_content' => '', + ) + ); + } + if ( ! get_option( 'rf_goal' ) ) { + update_option( 'rf_goal', 12 ); + } + // Starter books so the shelves aren't empty on first run. + if ( ! get_posts( array( 'post_type' => self::CPT, 'posts_per_page' => 1, 'post_status' => 'publish', 'fields' => 'ids' ) ) ) { + self::create_book( __( 'The Pragmatic Programmer', 'readforge' ), __( 'Hunt & Thomas', 'readforge' ), 'want', 0, 0 ); + self::create_book( __( 'Atomic Habits', 'readforge' ), __( 'James Clear', 'readforge' ), 'reading', 40, 0 ); + self::create_book( __( 'The Hobbit', 'readforge' ), __( 'J.R.R. Tolkien', 'readforge' ), 'finished', 100, 5 ); + } + flush_rewrite_rules(); + } + + private static function create_book( $title, $author, $shelf, $progress, $rating ) { + $id = wp_insert_post( + array( + 'post_type' => self::CPT, + 'post_status' => 'publish', + 'post_title' => sanitize_text_field( $title ), + ) + ); + if ( $id && ! is_wp_error( $id ) ) { + $today = current_time( 'Y-m-d' ); + update_post_meta( $id, '_rf_author', sanitize_text_field( $author ) ); + update_post_meta( $id, '_rf_shelf', array_key_exists( $shelf, self::shelves() ) ? $shelf : 'want' ); + update_post_meta( $id, '_rf_progress', max( 0, min( 100, (int) $progress ) ) ); + update_post_meta( $id, '_rf_rating', max( 0, min( 5, (int) $rating ) ) ); + if ( 'reading' === $shelf ) { + update_post_meta( $id, '_rf_started', $today ); + } + if ( 'finished' === $shelf ) { + update_post_meta( $id, '_rf_finished', $today ); + } + } + return $id; + } + + public static function admin_menu() { + add_menu_page( + __( 'Books', 'readforge' ), + __( 'Books', 'readforge' ), + 'edit_posts', + 'readforge', + function () { + wp_safe_redirect( home_url( '/' . self::PAGE_SLUG . '/' ) ); + exit; + }, + 'dashicons-book', + 34 + ); + } + + // ---------------------------------------------------------------- ajax -- + + private static function check_request() { + if ( ! current_user_can( 'edit_posts' ) || ! check_ajax_referer( self::NONCE, 'nonce', false ) ) { + wp_send_json_error( array( 'message' => 'Not allowed' ), 403 ); + } + } + + private static function get_book_or_fail( $id ) { + if ( get_post_type( $id ) !== self::CPT ) { + wp_send_json_error( array( 'message' => 'Bad request' ), 400 ); + } + return (int) $id; + } + + public static function ajax_add() { + self::check_request(); + $title = sanitize_text_field( wp_unslash( $_POST['title'] ?? '' ) ); + $author = sanitize_text_field( wp_unslash( $_POST['author'] ?? '' ) ); + $shelf = sanitize_key( $_POST['shelf'] ?? 'want' ); + if ( '' === $title ) { + wp_send_json_error( array( 'message' => 'Title required' ), 400 ); + } + $id = self::create_book( $title, $author, $shelf, 0, 0 ); + wp_send_json_success( array( 'id' => $id ) ); + } + + public static function ajax_move() { + self::check_request(); + $id = self::get_book_or_fail( (int) ( $_POST['book'] ?? 0 ) ); + $shelf = sanitize_key( $_POST['shelf'] ?? '' ); + if ( ! array_key_exists( $shelf, self::shelves() ) ) { + wp_send_json_error( array( 'message' => 'Bad shelf' ), 400 ); + } + $today = current_time( 'Y-m-d' ); + update_post_meta( $id, '_rf_shelf', $shelf ); + if ( 'reading' === $shelf && ! get_post_meta( $id, '_rf_started', true ) ) { + update_post_meta( $id, '_rf_started', $today ); + } + if ( 'finished' === $shelf ) { + update_post_meta( $id, '_rf_progress', 100 ); + update_post_meta( $id, '_rf_finished', $today ); + } else { + delete_post_meta( $id, '_rf_finished' ); + } + wp_send_json_success(); + } + + public static function ajax_progress() { + self::check_request(); + $id = self::get_book_or_fail( (int) ( $_POST['book'] ?? 0 ) ); + $progress = max( 0, min( 100, (int) ( $_POST['progress'] ?? 0 ) ) ); + update_post_meta( $id, '_rf_progress', $progress ); + wp_send_json_success( array( 'progress' => $progress ) ); + } + + public static function ajax_rate() { + self::check_request(); + $id = self::get_book_or_fail( (int) ( $_POST['book'] ?? 0 ) ); + $rating = max( 0, min( 5, (int) ( $_POST['rating'] ?? 0 ) ) ); + update_post_meta( $id, '_rf_rating', $rating ); + wp_send_json_success( array( 'rating' => $rating ) ); + } + + public static function ajax_goal() { + self::check_request(); + $goal = max( 1, min( 999, (int) ( $_POST['goal'] ?? 12 ) ) ); + update_option( 'rf_goal', $goal ); + wp_send_json_success( array( 'goal' => $goal ) ); + } + + public static function ajax_delete() { + self::check_request(); + $id = self::get_book_or_fail( (int) ( $_POST['book'] ?? 0 ) ); + wp_trash_post( $id ); + wp_send_json_success(); + } + + // -------------------------------------------------------------- render -- + + public static function maybe_render_app() { + if ( ! is_page( self::PAGE_SLUG ) ) { + return; + } + if ( ! is_user_logged_in() ) { + auth_redirect(); + } + self::render_app(); + exit; + } + + private static function render_app() { + $shelves = self::shelves(); + $books = get_posts( + array( + 'post_type' => self::CPT, + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'orderby' => 'modified', + 'order' => 'DESC', + ) + ); + $by_shelf = array_fill_keys( array_keys( $shelves ), array() ); + $year = (int) current_time( 'Y' ); + $done = 0; + foreach ( $books as $book ) { + $shelf = get_post_meta( $book->ID, '_rf_shelf', true ); + if ( ! isset( $by_shelf[ $shelf ] ) ) { + $shelf = 'want'; + } + $by_shelf[ $shelf ][] = $book; + if ( 'finished' === $shelf && (int) gmdate( 'Y', strtotime( (string) get_post_meta( $book->ID, '_rf_finished', true ) ) ) === $year ) { + $done++; + } + } + $goal = max( 1, (int) get_option( 'rf_goal', 12 ) ); + $nonce = wp_create_nonce( self::NONCE ); + + header( 'Content-Type: text/html; charset=utf-8' ); + ?> + +> +
+ + +