Commit 49efb238b8
Unsigned
Layout: unified · split
CODE_OF_CONDUCT.md +2 −2
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | ## Summary | 3 | ## Summary |
| 4 | 4 | ||
| 5 | Be logical and act in a way that represents your values. The following values | 5 | Be logical and act in a way that represents your values. The following values |
| 6 | are inherent for this project: | 6 | are inherent for this project: |
| 7 | 7 | ||
| 8 | - Respect | 8 | - Respect |
| @@ -11,5 +11,5 @@ are inherent for this project: | |||
| 11 | 11 | ||
| 12 | ## Reporting | 12 | ## Reporting |
| 13 | 13 | ||
| 14 | If you find anything that goes against this code of conduct or is offensive, | 14 | If you find anything that goes against this code of conduct or is offensive, |
| 15 | please report it to the maintainers. | 15 | please report it to the maintainers. |
CONTRIBUTING.md +2 −2
| @@ -10,6 +10,6 @@ This project really only has a few guidelines to follow: | |||
| 10 | 10 | ||
| 11 | ## Reporting Bugs | 11 | ## Reporting Bugs |
| 12 | 12 | ||
| 13 | I don't use a report template, so just remember to give as much detail as | 13 | I don't use a report template, so just remember to give as much detail as |
| 14 | possible about the bug you're experiencing. If there's not enough detail | 14 | possible about the bug you're experiencing. If there's not enough detail |
| 15 | reported, emails will need to be exchanged before anyone can look into the bug. | 15 | reported, emails will need to be exchanged before anyone can look into the bug. |
README.md +18 −16
| @@ -1,18 +1,18 @@ | |||
| 1 | # hn | 1 | # hn |
| 2 | 2 | ||
| 3 | [hn](https://hn.cleberg.net) is a simple front-end alternative for Hacker | 3 | [hn](https://hn.cleberg.net) is a simple front-end alternative for Hacker |
| 4 | News, focusing on privacy and simplicity. | 4 | News, focusing on privacy and simplicity. |
| 5 | 5 | ||
| 6 | ## Getting Started | 6 | ## Getting Started |
| 7 | 7 | ||
| 8 | These instructions will get you a copy of the project up and running on your | 8 | These instructions will get you a copy of the project up and running on your |
| 9 | local machine for development and testing purposes. See deployment for notes on | 9 | local machine for development and testing purposes. See deployment for notes on |
| 10 | how to deploy the project on a live system. | 10 | how to deploy the project on a live system. |
| 11 | 11 | ||
| 12 | ### Prerequisites | 12 | ### Prerequisites |
| 13 | 13 | ||
| 14 | - A web server (e.g., Nginx or Apache) | 14 | - A web server (e.g., Nginx or Apache) |
| 15 | - PHP | 15 | - PHP (>= v8.0) |
| 16 | - Optional: minify | 16 | - Optional: minify |
| 17 | 17 | ||
| 18 | ### Installing | 18 | ### Installing |
| @@ -20,7 +20,7 @@ how to deploy the project on a live system. | |||
| 20 | Install the dependencies, using the web server of your choice: | 20 | Install the dependencies, using the web server of your choice: |
| 21 | 21 | ||
| 22 | ``` | 22 | ``` |
| 23 | sudo apt install nginx-full php minify | 23 | sudo apt install nginx-full php php-cgi php-fpm minify |
| 24 | ``` | 24 | ``` |
| 25 | 25 | ||
| 26 | Clone the repo: | 26 | Clone the repo: |
| @@ -38,11 +38,11 @@ minify -o static/styles.min.css static/styles.css | |||
| 38 | 38 | ||
| 39 | ## Deployment | 39 | ## Deployment |
| 40 | 40 | ||
| 41 | Deployment is as easy as copying the code to your webroot. No special packages | 41 | Deployment is as easy as copying the code to your webroot. No special packages |
| 42 | or tools required. | 42 | or tools required. |
| 43 | 43 | ||
| 44 | To deploy, ensure you have a publicly-available web server and configure it to | 44 | To deploy, ensure you have a publicly-available web server and configure it to |
| 45 | fallback with all errors to the `index.php` file rather than returning a `404` | 45 | fallback with all errors to the `index.php` file rather than returning a `404` |
| 46 | error. | 46 | error. |
| 47 | 47 | ||
| 48 | For nginx, include the following snippet in your website's conf file: | 48 | For nginx, include the following snippet in your website's conf file: |
| @@ -55,7 +55,7 @@ location / { | |||
| 55 | } | 55 | } |
| 56 | ``` | 56 | ``` |
| 57 | 57 | ||
| 58 | For Apache, you can include the following snippet in a `.htaccess` file within | 58 | For Apache, you can include the following snippet in a `.htaccess` file within |
| 59 | the directory you're serving the PHP file from: | 59 | the directory you're serving the PHP file from: |
| 60 | 60 | ||
| 61 | ```conf | 61 | ```conf |
| @@ -66,12 +66,13 @@ FallbackResource /index.php | |||
| 66 | 66 | ||
| 67 | * [PHP](https://www.php.net/) - The scripting language | 67 | * [PHP](https://www.php.net/) - The scripting language |
| 68 | * [HTML](https://html.spec.whatwg.org/multipage/) - The markup language | 68 | * [HTML](https://html.spec.whatwg.org/multipage/) - The markup language |
| 69 | * [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to | 69 | * [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to |
| 70 | minify CSS | 70 | minify CSS |
| 71 | 71 | ||
| 72 | ## Contributing | 72 | ## Contributing |
| 73 | 73 | ||
| 74 | Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of | 74 | Please read [CONTRIBUTING.md](./CONTRIBUTING.md) and |
| 75 | [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for details on our code of | ||
| 75 | conduct, and the process for submitting pull requests to us. | 76 | conduct, and the process for submitting pull requests to us. |
| 76 | 77 | ||
| 77 | ### TODO | 78 | ### TODO |
| @@ -79,9 +80,10 @@ conduct, and the process for submitting pull requests to us. | |||
| 79 | A scratch pad of ideas that may be useful to implement: | 80 | A scratch pad of ideas that may be useful to implement: |
| 80 | 81 | ||
| 81 | - [x] Add minimal CSS. | 82 | - [x] Add minimal CSS. |
| 82 | - [~] Add functionality to view a user's profile. | 83 | - [x] Add functionality to view a user's profile. |
| 83 | - [ ] Add functionality to view item-specific page with comments. | 84 | - [ ] Add functionality to view item-specific page with comments (`ConstructStoryDiscussion`). |
| 84 | - [ ] Add functionality to load more items or paginate? | 85 | - [ ] Add functionality to handle polls (`ConstructPoll` & `ConstructPollOpt`). |
| 86 | - [ ] Add functionality to load more items or paginate. | ||
| 85 | 87 | ||
| 86 | ## Versioning | 88 | ## Versioning |
| 87 | 89 | ||
| @@ -93,5 +95,5 @@ This project currently doesn't use versioning. See the git log instead. | |||
| 93 | 95 | ||
| 94 | ## License | 96 | ## License |
| 95 | 97 | ||
| 96 | This project is licensed under the Unlicense - see the | 98 | This project is licensed under the Unlicense - see the |
| 97 | [LICENSE.md](./LICENSE.md) file for details. | 99 | [LICENSE.md](./LICENSE.md) file for details. |
index.php +6 −232
| @@ -1,236 +1,10 @@ | |||
| 1 | <?php | 1 | <?php |
| 2 | 2 | ||
| 3 | $full_domain = 'https://hn.cleberg.net'; | 3 | require_once 'src/Controller/RouteController.php'; |
| 4 | $path = ltrim($_SERVER['REQUEST_URI'], '/'); | ||
| 5 | $elements = explode('/', $path); | ||
| 6 | 4 | ||
| 7 | if (empty($elements[0])) { | 5 | $GLOBALS['full_domain'] = 'https://hn.cleberg.net'; |
| 8 | $html_output = get_stories( | 6 | $GLOBALS['author_name'] = 'cmc'; |
| 9 | 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"', | 7 | $GLOBALS['site_title'] = 'hn'; |
| 10 | 'Top' | ||
| 11 | ); | ||
| 12 | echo_html( | ||
| 13 | $GLOBALS['full_domain'], | ||
| 14 | 'The top stories from Hacker News, proxied by hn.', | ||
| 15 | 'hn', | ||
| 16 | $html_output | ||
| 17 | ); | ||
| 18 | } else { | ||
| 19 | switch (array_shift($elements)) { | ||
| 20 | case 'top': | ||
| 21 | $html_output = get_stories( | ||
| 22 | 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"', | ||
| 23 | 'Top' | ||
| 24 | ); | ||
| 25 | echo_html( | ||
| 26 | $GLOBALS['full_domain'], | ||
| 27 | 'The top stories from Hacker News, proxied by hn.', | ||
| 28 | 'hn', | ||
| 29 | $html_output | ||
| 30 | ); | ||
| 31 | break; | ||
| 32 | 8 | ||
| 33 | case 'best': | 9 | $route = new HN\Controllers\RouteController($_SERVER['REQUEST_URI']); |
| 34 | $html_output = get_stories( | 10 | $route->routeUser(); |
| 35 | 'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"', | ||
| 36 | 'Best' | ||
| 37 | ); | ||
| 38 | echo_html( | ||
| 39 | $GLOBALS['full_domain'] . '/best/', | ||
| 40 | 'The best 30 stories from Hacker News, proxied by hn.', | ||
| 41 | 'hn ~ best', | ||
| 42 | $html_output | ||
| 43 | ); | ||
| 44 | break; | ||
| 45 | |||
| 46 | case 'new': | ||
| 47 | $html_output = get_stories( | ||
| 48 | 'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"', | ||
| 49 | 'New' | ||
| 50 | ); | ||
| 51 | echo_html( | ||
| 52 | $GLOBALS['full_domain'] . '/new/', | ||
| 53 | 'The newest 30 stories from Hacker News, proxied by hn.', | ||
| 54 | 'hn ~ new', | ||
| 55 | $html_output | ||
| 56 | ); | ||
| 57 | break; | ||
| 58 | |||
| 59 | case 'ask': | ||
| 60 | $html_output = get_stories( | ||
| 61 | 'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"', | ||
| 62 | 'Ask' | ||
| 63 | ); | ||
| 64 | echo_html( | ||
| 65 | $GLOBALS['full_domain'] . '/ask/', | ||
| 66 | 'The latest 30 asks from Hacker News, proxied by hn.', | ||
| 67 | 'hn ~ ask', | ||
| 68 | $html_output | ||
| 69 | ); | ||
| 70 | break; | ||
| 71 | |||
| 72 | case 'show': | ||
| 73 | $html_output = get_stories( | ||
| 74 | 'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"', | ||
| 75 | 'Show' | ||
| 76 | ); | ||
| 77 | echo_html( | ||
| 78 | $GLOBALS['full_domain'] . '/show/', | ||
| 79 | 'The latest 30 show stories from Hacker News, proxied by hn.', | ||
| 80 | 'hn ~ show', | ||
| 81 | $html_output | ||
| 82 | ); | ||
| 83 | break; | ||
| 84 | |||
| 85 | case 'job': | ||
| 86 | $html_output = get_stories( | ||
| 87 | 'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"', | ||
| 88 | 'Job' | ||
| 89 | ); | ||
| 90 | echo_html( | ||
| 91 | $GLOBALS['full_domain'] . '/job/', | ||
| 92 | 'The latest 30 job posts from Hacker News, proxied by hn.', | ||
| 93 | 'hn ~ job', | ||
| 94 | $html_output | ||
| 95 | ); | ||
| 96 | break; | ||
| 97 | |||
| 98 | case 'user': | ||
| 99 | $html_output = get_user( | ||
| 100 | 'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json', | ||
| 101 | 'User Profile: ' . $elements[0] | ||
| 102 | ); | ||
| 103 | echo_html( | ||
| 104 | $GLOBALS['full_domain'] . '/user/' . $elements[0], | ||
| 105 | 'The Hacker News profile for ' . $elements[0] . ', proxied by hn.', | ||
| 106 | 'hn', | ||
| 107 | $html_output | ||
| 108 | ); | ||
| 109 | break; | ||
| 110 | |||
| 111 | default: | ||
| 112 | header('HTTP/1.1 404 Not Found'); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | /** | ||
| 117 | * Extract a set of stories from Hacker News API and format in HTML | ||
| 118 | * | ||
| 119 | * @access public | ||
| 120 | * @param string $api_url The API endpoint to use for extraction | ||
| 121 | * @param string $inline_title The <h1> title to use in the HTML | ||
| 122 | * @return string $html_output The formatted HTML result of stories from the API | ||
| 123 | * @author cmc <hello@cleberg.net> | ||
| 124 | */ | ||
| 125 | function get_stories(string $api_url, string $inline_title): string | ||
| 126 | { | ||
| 127 | $response_raw = file_get_contents($api_url); | ||
| 128 | if ($response_raw == "null") { | ||
| 129 | return '<p>ERROR: Stories not found. API returned `null`.</p>'; | ||
| 130 | } else { | ||
| 131 | $response = json_decode($response_raw, true); | ||
| 132 | } | ||
| 133 | |||
| 134 | $html_output = '<h1>' . $inline_title . '</h1>'; | ||
| 135 | |||
| 136 | for ($i = 0; $i < count($response); $i++) { | ||
| 137 | $sub_url = 'https://hacker-news.firebaseio.com/v0/item/' . $response[$i] . '.json'; | ||
| 138 | $sub_response_raw = file_get_contents($sub_url); | ||
| 139 | $sub_response = json_decode($sub_response_raw, true); | ||
| 140 | |||
| 141 | // TODO: Can this be converted to a heredoc string with variables? | ||
| 142 | $html = '<div><a href="' . $sub_response['url'] . '">' . $sub_response['title'] . '</a>'; | ||
| 143 | $html .= '<p><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']) . '">'; | ||
| 144 | $html .= date('Y-m-d h:m:s', $sub_response['time']) . '</time> by <a'; | ||
| 145 | $html .= ' href="/user/' . $sub_response['by'] . '">'; | ||
| 146 | $html .= $sub_response['by'] . '</a> | ' . $sub_response['score']; | ||
| 147 | $html .= ' points</p></div>'; | ||
| 148 | $html_output .= $html; | ||
| 149 | } | ||
| 150 | |||
| 151 | return $html_output; | ||
| 152 | } | ||
| 153 | |||
| 154 | /** | ||
| 155 | *Extract a user's profile from Hacker News API and format in HTML | ||
| 156 | * | ||
| 157 | * @access public | ||
| 158 | * @param string $api_url The API endpoint to use for extraction | ||
| 159 | * @param string $inline_title The <h1> title to use in the HTML | ||
| 160 | * @return string $html_output The formatted HTML result of stories from the API | ||
| 161 | * @author cmc <hello@cleberg.net> | ||
| 162 | */ | ||
| 163 | function get_user(string $api_url, string $inline_title): string | ||
| 164 | { | ||
| 165 | $response_raw = file_get_contents($api_url); | ||
| 166 | if ($response_raw == "null") { | ||
| 167 | return '<p>ERROR: User not found.</p>'; | ||
| 168 | } else { | ||
| 169 | $response = json_decode($response_raw, true); | ||
| 170 | } | ||
| 171 | |||
| 172 | // TODO: Can this be converted to a heredoc string with variables? | ||
| 173 | $html_output = '<h1>' . $inline_title . '</h1>'; | ||
| 174 | $html_output .= '<p>About: ' . $response['about'] . '</p>'; | ||
| 175 | $html_output .= '<p>Karma: ' . $response['karma'] . '</p>'; | ||
| 176 | $html_output .= '<p>Created: <time datetime="' . date('Y-m-d h:m:s', $response['created']) . '>' . date('Y-m-d h:m:s', $response['created']) . '</time></p>'; | ||
| 177 | $html_output .= '<p>Recently Submitted Posts:</p>'; | ||
| 178 | |||
| 179 | $limit = count($response['submitted']) > 10 ? 10 : count($response['submitted']); | ||
| 180 | if (count($response['submitted']) > 0) { | ||
| 181 | for ($i = 0; $i < $limit; $i++) { | ||
| 182 | $sub_url = 'https://hacker-news.firebaseio.com/v0/item/' . $response['submitted'][$i] . '.json'; | ||
| 183 | $sub_response_raw = file_get_contents($sub_url); | ||
| 184 | $sub_response = json_decode($sub_response_raw, true); | ||
| 185 | |||
| 186 | if ($sub_response['type'] == 'story' || $sub_response['type'] == 'job') { | ||
| 187 | $html = '<div><a href="' . $sub_response['url'] . '">' . $sub_response['title'] . '</a>'; | ||
| 188 | $html .= '<p><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']) . '">'; | ||
| 189 | $html .= date('Y-m-d h:m:s', $sub_response['time']) . '</time> by <a'; | ||
| 190 | $html .= ' href="/user/' . $sub_response['by'] . '">'; | ||
| 191 | $html .= $sub_response['by'] . '</a> | ' . $sub_response['score']; | ||
| 192 | $html .= ' points</p></div>'; | ||
| 193 | } elseif ($sub_response['type'] == 'poll') { | ||
| 194 | // TODO: Handle polls | ||
| 195 | $html = 'TODO: Add logic to handle polls here.'; | ||
| 196 | } else { | ||
| 197 | // TODO: Add link to parent with $sub_response['parent'] | ||
| 198 | $html = '<div><time datetime="' . date('Y-m-d h:m:s', $sub_response['time']); | ||
| 199 | $html .= '">' . date('Y-m-d h:m:s', $sub_response['time']); | ||
| 200 | $html .= '</time><br><p>' . $sub_response['text'] . '</p></div>'; | ||
| 201 | } | ||
| 202 | $html_output .= $html; | ||
| 203 | } | ||
| 204 | } else { | ||
| 205 | $html_output .= '<p>User has no submissions.</p>'; | ||
| 206 | } | ||
| 207 | |||
| 208 | return $html_output; | ||
| 209 | } | ||
| 210 | |||
| 211 | |||
| 212 | /** | ||
| 213 | * Send formatted HTML results to the user via a template | ||
| 214 | * | ||
| 215 | * @access public | ||
| 216 | * @param string $page_url Canonical URL for HTML header | ||
| 217 | * @param string $page_description Page description for HTML header | ||
| 218 | * @param string $page_title Page title for HTML header | ||
| 219 | * @param string $page_content Page content to display in <main> | ||
| 220 | * @author cmc <hello@cleberg.net> | ||
| 221 | */ | ||
| 222 | function echo_html(string $page_url, string $page_description, string $page_title, string $page_content) | ||
| 223 | { | ||
| 224 | include_once 'src/View/class-template.php'; | ||
| 225 | |||
| 226 | $template = new HN\View\Template( | ||
| 227 | $page_url, | ||
| 228 | $page_description, | ||
| 229 | $page_title, | ||
| 230 | $page_content | ||
| 231 | ); | ||
| 232 | |||
| 233 | $template->echo_template('templates/template.html'); | ||
| 234 | } | ||
| 235 | |||
| 236 | // EOF | ||
src/Controller/FeedController.php added +47
| @@ -0,0 +1,47 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | namespace HN\Controllers; | ||
| 4 | |||
| 5 | class FeedController | ||
| 6 | { | ||
| 7 | /** | ||
| 8 | * @var string | ||
| 9 | */ | ||
| 10 | private string $canonical_url; | ||
| 11 | /** | ||
| 12 | * @var string | ||
| 13 | */ | ||
| 14 | private string $description; | ||
| 15 | /** | ||
| 16 | * @var string | ||
| 17 | */ | ||
| 18 | private string $title; | ||
| 19 | /** | ||
| 20 | * @var string | ||
| 21 | */ | ||
| 22 | private string $content; | ||
| 23 | /** | ||
| 24 | * @var false|string | ||
| 25 | */ | ||
| 26 | private mixed $current_year; | ||
| 27 | |||
| 28 | public function __construct(string $canonical_url, string $description, string $title, string $content) | ||
| 29 | { | ||
| 30 | $this->canonical_url = $canonical_url; | ||
| 31 | $this->description = $description; | ||
| 32 | $this->title = $title; | ||
| 33 | $this->content = $content; | ||
| 34 | $this->current_year = date("Y"); | ||
| 35 | } | ||
| 36 | |||
| 37 | /** | ||
| 38 | * Request template to be presented to the user | ||
| 39 | * | ||
| 40 | * @access public | ||
| 41 | * @author cmc <hello@cleberg.net> | ||
| 42 | */ | ||
| 43 | public function render(): void | ||
| 44 | { | ||
| 45 | include_once 'src/View/BaseTemplate.php'; | ||
| 46 | } | ||
| 47 | } | ||
src/Controller/RouteController.php added +171
| @@ -0,0 +1,171 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | namespace HN\Controllers; | ||
| 4 | |||
| 5 | require_once 'src/Controller/FeedController.php'; | ||
| 6 | |||
| 7 | use function HN\Models\GetApiResults; | ||
| 8 | use function HN\Models\ParseItem; | ||
| 9 | use function HN\Models\ParseStories; | ||
| 10 | use function HN\Models\ParseUser; | ||
| 11 | |||
| 12 | class RouteController | ||
| 13 | { | ||
| 14 | /** | ||
| 15 | * @var string | ||
| 16 | */ | ||
| 17 | private string $request; | ||
| 18 | |||
| 19 | public function __construct(string $request) | ||
| 20 | { | ||
| 21 | $this->request = $request; | ||
| 22 | } | ||
| 23 | |||
| 24 | /** | ||
| 25 | * Route the user to the appropriate function, based on the URL | ||
| 26 | * | ||
| 27 | * @access public | ||
| 28 | * @return void No return type; send user to FeedController->render() or a 404 error | ||
| 29 | * @author cmc <hello@cleberg.net> | ||
| 30 | */ | ||
| 31 | public function routeUser(): void | ||
| 32 | { | ||
| 33 | include_once 'src/Model/ApiService.php'; | ||
| 34 | $path = ltrim($this->request, '/'); | ||
| 35 | $elements = explode('/', $path); | ||
| 36 | |||
| 37 | switch (array_shift($elements)) { | ||
| 38 | case '': | ||
| 39 | case 'top': | ||
| 40 | $feed = new FeedController( | ||
| 41 | $GLOBALS['full_domain'], | ||
| 42 | 'The top stories from Hacker News, proxied by hn.', | ||
| 43 | 'hn', | ||
| 44 | ParseStories( | ||
| 45 | GetApiResults( | ||
| 46 | 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"' | ||
| 47 | ), | ||
| 48 | 'Top' | ||
| 49 | ) | ||
| 50 | ); | ||
| 51 | |||
| 52 | $feed->render(); | ||
| 53 | break; | ||
| 54 | |||
| 55 | case 'best': | ||
| 56 | $feed = new FeedController( | ||
| 57 | $GLOBALS['full_domain'] . '/Best/', | ||
| 58 | 'The best stories from Hacker News, proxied by hn.', | ||
| 59 | 'hn ~ best', | ||
| 60 | ParseStories( | ||
| 61 | GetApiResults( | ||
| 62 | 'https://hacker-news.firebaseio.com/v0/beststories.json?limitToFirst=10&orderBy="$key"' | ||
| 63 | ), | ||
| 64 | 'Best' | ||
| 65 | ) | ||
| 66 | ); | ||
| 67 | |||
| 68 | $feed->render(); | ||
| 69 | break; | ||
| 70 | |||
| 71 | case 'new': | ||
| 72 | $feed = new FeedController( | ||
| 73 | $GLOBALS['full_domain'] . '/new/', | ||
| 74 | 'The newest stories from Hacker News, proxied by hn.', | ||
| 75 | 'hn ~ new', | ||
| 76 | ParseStories( | ||
| 77 | GetApiResults( | ||
| 78 | 'https://hacker-news.firebaseio.com/v0/newstories.json?limitToFirst=10&orderBy="$key"' | ||
| 79 | ), | ||
| 80 | 'New' | ||
| 81 | ) | ||
| 82 | ); | ||
| 83 | |||
| 84 | $feed->render(); | ||
| 85 | break; | ||
| 86 | |||
| 87 | case 'ask': | ||
| 88 | $feed = new FeedController( | ||
| 89 | $GLOBALS['full_domain'] . '/ask/', | ||
| 90 | 'The top asks from Hacker News, proxied by hn.', | ||
| 91 | 'hn ~ ask', | ||
| 92 | ParseStories( | ||
| 93 | GetApiResults( | ||
| 94 | 'https://hacker-news.firebaseio.com/v0/askstories.json?limitToFirst=10&orderBy="$key"' | ||
| 95 | ), | ||
| 96 | 'Ask' | ||
| 97 | ) | ||
| 98 | ); | ||
| 99 | |||
| 100 | $feed->render(); | ||
| 101 | break; | ||
| 102 | |||
| 103 | case 'show': | ||
| 104 | $feed = new FeedController( | ||
| 105 | $GLOBALS['full_domain'] . '/show/', | ||
| 106 | 'The latest showcases from Hacker News, proxied by hn.', | ||
| 107 | 'hn ~ show', | ||
| 108 | ParseStories( | ||
| 109 | GetApiResults( | ||
| 110 | 'https://hacker-news.firebaseio.com/v0/showstories.json?limitToFirst=10&orderBy="$key"' | ||
| 111 | ), | ||
| 112 | 'Show' | ||
| 113 | ) | ||
| 114 | ); | ||
| 115 | |||
| 116 | $feed->render(); | ||
| 117 | break; | ||
| 118 | |||
| 119 | case 'job': | ||
| 120 | $feed = new FeedController( | ||
| 121 | $GLOBALS['full_domain'] . '/job/', | ||
| 122 | 'The latest jobs from Hacker News, proxied by hn.', | ||
| 123 | 'hn ~ jobs', | ||
| 124 | ParseStories( | ||
| 125 | GetApiResults( | ||
| 126 | 'https://hacker-news.firebaseio.com/v0/jobstories.json?limitToFirst=10&orderBy="$key"' | ||
| 127 | ), | ||
| 128 | 'Job' | ||
| 129 | ) | ||
| 130 | ); | ||
| 131 | |||
| 132 | $feed->render(); | ||
| 133 | break; | ||
| 134 | |||
| 135 | case 'user': | ||
| 136 | $feed = new FeedController( | ||
| 137 | $GLOBALS['full_domain'] . '/user/' . $elements[0], | ||
| 138 | 'The Hacker News profile for ' . $elements[0] . ', proxied by hn.', | ||
| 139 | 'hn ~ ' . $elements[0], | ||
| 140 | ParseUser( | ||
| 141 | GetApiResults( | ||
| 142 | 'https://hacker-news.firebaseio.com/v0/user/' . $elements[0] . '.json' | ||
| 143 | ), | ||
| 144 | 'User: ' . $elements[0] | ||
| 145 | ) | ||
| 146 | ); | ||
| 147 | |||
| 148 | $feed->render(); | ||
| 149 | break; | ||
| 150 | |||
| 151 | case 'item': | ||
| 152 | $feed = new FeedController( | ||
| 153 | $GLOBALS['full_domain'] . '/item/' . $elements[0], | ||
| 154 | 'Hacker News story ' . $elements[0] . ', proxied by hn.', | ||
| 155 | 'hn ~ ' . $elements[0], | ||
| 156 | ParseItem( | ||
| 157 | GetApiResults( | ||
| 158 | 'https://hacker-news.firebaseio.com/v0/item/' . $elements[0] . '.json' | ||
| 159 | ), | ||
| 160 | 'Item: ' . $elements[0] | ||
| 161 | ) | ||
| 162 | ); | ||
| 163 | |||
| 164 | $feed->render(); | ||
| 165 | break; | ||
| 166 | |||
| 167 | default: | ||
| 168 | header('HTTP/1.1 404 Not Found'); | ||
| 169 | } | ||
| 170 | } | ||
| 171 | }\ No newline at end of file | ||
src/Model/ApiService.php added +218
| @@ -0,0 +1,218 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | namespace HN\Models; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Extract a set of stories from the Hacker News API | ||
| 7 | * | ||
| 8 | * @access public | ||
| 9 | * @param string $api_url The API endpoint to use for extraction | ||
| 10 | * @return mixed The API results formatted into an HTML section | ||
| 11 | * @author cmc <hello@cleberg.net> | ||
| 12 | */ | ||
| 13 | function GetApiResults(string $api_url): mixed | ||
| 14 | { | ||
| 15 | $response = file_get_contents($api_url); | ||
| 16 | return json_decode($response, true); | ||
| 17 | } | ||
| 18 | |||
| 19 | /** | ||
| 20 | * Formats a given set of API results into an HTML section | ||
| 21 | * | ||
| 22 | * @access public | ||
| 23 | * @param mixed $api_results The decoded API results | ||
| 24 | * @param string $inline_title The <h1> title to use in the HTML | ||
| 25 | * @return string $html_output The formatted HTML result of stories from the API or the error message | ||
| 26 | * @author cmc <hello@cleberg.net> | ||
| 27 | */ | ||
| 28 | function ParseStories(mixed $api_results, string $inline_title): string | ||
| 29 | { | ||
| 30 | if ($api_results == "null") { | ||
| 31 | return '<p>ERROR: Stories not found. API returned `null`.</p>'; | ||
| 32 | } else { | ||
| 33 | $html_output = '<h1>' . $inline_title . '</h1>'; | ||
| 34 | for ($i = 0; $i < count($api_results); $i++) { | ||
| 35 | $story_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results[$i] . '.json'); | ||
| 36 | $html_output .= ConstructStory($story_api_results); | ||
| 37 | } | ||
| 38 | |||
| 39 | return $html_output; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | /** | ||
| 44 | *Extract a user's profile from Hacker News API and format in HTML | ||
| 45 | * | ||
| 46 | * @access public | ||
| 47 | * @param mixed $api_results The decoded API results | ||
| 48 | * @param string $inline_title The <h1> title to use in the HTML | ||
| 49 | * @return string $html_output The formatted HTML result of stories from the API | ||
| 50 | * @author cmc <hello@cleberg.net> | ||
| 51 | */ | ||
| 52 | function ParseUser(mixed $api_results, string $inline_title): string | ||
| 53 | { | ||
| 54 | if ($api_results == "null") { | ||
| 55 | return '<p>ERROR: User not found.</p>'; | ||
| 56 | } else { | ||
| 57 | $about = $api_results['about']; | ||
| 58 | $karma = $api_results['karma']; | ||
| 59 | $created = date('Y-m-d h:m:s', $api_results['created']); | ||
| 60 | |||
| 61 | $html_output = <<<EOT | ||
| 62 | <div class="user-details"> | ||
| 63 | <h1>$inline_title</h1> | ||
| 64 | <p>About: $about</p> | ||
| 65 | <p>Karma: $karma</p> | ||
| 66 | <p>Created: <time datetime="$created">$created</time></p> | ||
| 67 | <br> | ||
| 68 | <h2>Recently Submitted</h2> | ||
| 69 | </div> | ||
| 70 | EOT; | ||
| 71 | |||
| 72 | $limit = (count($api_results['submitted']) > 10) ? 10 : count($api_results['submitted']); | ||
| 73 | if (count($api_results['submitted']) > 0) { | ||
| 74 | for ($i = 0; $i < $limit; $i++) { | ||
| 75 | $user_api_results = GetApiResults('https://hacker-news.firebaseio.com/v0/item/' . $api_results['submitted'][$i] . '.json'); | ||
| 76 | $html_output .= GetItem($user_api_results); | ||
| 77 | } | ||
| 78 | } else { | ||
| 79 | $html_output .= '<p>User has no submissions.</p>'; | ||
| 80 | } | ||
| 81 | |||
| 82 | return $html_output; | ||
| 83 | } | ||
| 84 | } | ||
| 85 | |||
| 86 | |||
| 87 | /** | ||
| 88 | * Formats one specific item requested by the user | ||
| 89 | * | ||
| 90 | * @access public | ||
| 91 | * @param mixed $api_results The decoded API results | ||
| 92 | * @param string $inline_title The <h1> title to use in the HTML | ||
| 93 | * @return string $html_output The formatted HTML result of stories from the API or the error message | ||
| 94 | * @author cmc <hello@cleberg.net> | ||
| 95 | */ | ||
| 96 | function ParseItem(mixed $api_results, string $inline_title): string | ||
| 97 | { | ||
| 98 | // TODO: Need to create a page specifially for /item/ requests | ||
| 99 | // TODO: Use the GetItem() and Construct*() functions below, then output in it's own page - possibly with a single parent and descendat, if exist | ||
| 100 | return 'TODO'; | ||
| 101 | } | ||
| 102 | |||
| 103 | |||
| 104 | /** | ||
| 105 | * Formats one item from the API to HTML | ||
| 106 | * | ||
| 107 | * @access public | ||
| 108 | * @param mixed $api_results The decoded API results | ||
| 109 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 110 | * @author cmc <hello@cleberg.net> | ||
| 111 | */ | ||
| 112 | function GetItem(mixed $api_results): string | ||
| 113 | { | ||
| 114 | $type = $api_results['type']; | ||
| 115 | |||
| 116 | return match ($type) { | ||
| 117 | 'story', 'job' => ConstructStory($api_results), | ||
| 118 | 'comment' => ConstructComment($api_results), | ||
| 119 | 'poll' => ConstructPoll($api_results), | ||
| 120 | 'pollopt' => ConstructPollOpt($api_results), | ||
| 121 | default => 'ERROR: Item type not found.', | ||
| 122 | }; | ||
| 123 | } | ||
| 124 | |||
| 125 | |||
| 126 | /** | ||
| 127 | * Creates a story HTML element | ||
| 128 | * | ||
| 129 | * @access public | ||
| 130 | * @param mixed $api_results The decoded API results | ||
| 131 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 132 | * @author cmc <hello@cleberg.net> | ||
| 133 | */ | ||
| 134 | function ConstructStory(mixed $api_results): string | ||
| 135 | { | ||
| 136 | $id = $api_results['id']; | ||
| 137 | $url = $api_results['url']; | ||
| 138 | $title = $api_results['title']; | ||
| 139 | $time = date('Y-m-d h:m:s', $api_results['time']); | ||
| 140 | $by = $api_results['by']; | ||
| 141 | $score = $api_results['score']; | ||
| 142 | $descendants = $api_results['descendants']; | ||
| 143 | |||
| 144 | return <<<EOT | ||
| 145 | <div class="story"> | ||
| 146 | <a href="$url">$title</a> | ||
| 147 | <p> | ||
| 148 | <time datetime="$time">$time</time> | ||
| 149 | by <a href="/user/$by/">$by</a> | ||
| 150 | | $score points | ||
| 151 | | <a href="/item/$id">$descendants comments</a> | ||
| 152 | </p> | ||
| 153 | </div> | ||
| 154 | EOT; | ||
| 155 | } | ||
| 156 | |||
| 157 | /** | ||
| 158 | * Creates a story discussion page with comments | ||
| 159 | * | ||
| 160 | * @access public | ||
| 161 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 162 | * @author cmc <hello@cleberg.net> | ||
| 163 | */ | ||
| 164 | function ConstructStoryDiscussion(): string | ||
| 165 | { | ||
| 166 | // TODO: Create discussion page, using mostly the same details as ConstructStory - except you need to check for $api_results['text'] to see if the poster left text in addition to the link. | ||
| 167 | // : Also need to show comments (at least a list of top level ones to start) | ||
| 168 | return 'TODO'; | ||
| 169 | } | ||
| 170 | |||
| 171 | /** | ||
| 172 | * Creates a comment HTML element | ||
| 173 | * | ||
| 174 | * @access public | ||
| 175 | * @param mixed $api_results The decoded API results | ||
| 176 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 177 | * @author cmc <hello@cleberg.net> | ||
| 178 | */ | ||
| 179 | function ConstructComment($api_results): string | ||
| 180 | { | ||
| 181 | $time = date('Y-m-d h:m:s', $api_results['time']); | ||
| 182 | $text = $api_results['text']; | ||
| 183 | $parent = $api_results['parent']; | ||
| 184 | |||
| 185 | return <<<EOT | ||
| 186 | <div class="comment"> | ||
| 187 | <p>$text</p> | ||
| 188 | <p><i>Submitted in response to: <a href="/item/$parent/">$parent</a></i></p> | ||
| 189 | <time datetime="$time">$time</time> | ||
| 190 | </div> | ||
| 191 | EOT; | ||
| 192 | } | ||
| 193 | |||
| 194 | /** | ||
| 195 | * Creates a poll HTML element | ||
| 196 | * | ||
| 197 | * @access public | ||
| 198 | * @param mixed $api_results The decoded API results | ||
| 199 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 200 | * @author cmc <hello@cleberg.net> | ||
| 201 | */ | ||
| 202 | function ConstructPoll($api_results): string | ||
| 203 | { | ||
| 204 | return 'TODO'; | ||
| 205 | } | ||
| 206 | |||
| 207 | /** | ||
| 208 | * Creates a poll-option HTML element | ||
| 209 | * | ||
| 210 | * @access public | ||
| 211 | * @param mixed $api_results The decoded API results | ||
| 212 | * @return string The formatted HTML result of stories from the API or the error message | ||
| 213 | * @author cmc <hello@cleberg.net> | ||
| 214 | */ | ||
| 215 | function ConstructPollOpt($api_results): string | ||
| 216 | { | ||
| 217 | return 'TODO'; | ||
| 218 | }\ No newline at end of file | ||
src/Model/CacheService.php added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | /** | ||
| 4 | * TODO: Implement a way to cache certain content in SQLite or something similarly performative | ||
| 5 | * | ||
| 6 | * @access public | ||
| 7 | * @return void | ||
| 8 | * @author cmc <hello@cleberg.net> | ||
| 9 | */ | ||
| 10 | function CacheService() {}\ No newline at end of file | ||
src/View/BaseTemplate.php added +35
| @@ -0,0 +1,35 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | |||
| 4 | <head> | ||
| 5 | <title><?php echo $this->title; ?></title> | ||
| 6 | <meta charset="UTF-8"> | ||
| 7 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||
| 8 | <meta http-equiv="x-ua-compatible" content="ie=edge"> | ||
| 9 | <meta name="author" content="<?php echo $GLOBALS['author_name']; ?>"> | ||
| 10 | <meta name="description" content="<?php echo $this->description; ?>"> | ||
| 11 | <link rel="canonical" href="<?php echo $this->canonical_url; ?>"> | ||
| 12 | <link rel="stylesheet" href="/static/styles.min.css"> | ||
| 13 | </head> | ||
| 14 | |||
| 15 | <body> | ||
| 16 | <main id="main"> | ||
| 17 | <nav class="links"> | ||
| 18 | <span><a href="/">Top</a> · </span> | ||
| 19 | <span><a href="/best/">Best</a> ·</span> | ||
| 20 | <span><a href="/new/">New</a> ·</span> | ||
| 21 | <span><a href="/ask/">Ask</a> ·</span> | ||
| 22 | <span><a href="/show/">Show</a> ·</span> | ||
| 23 | <span><a href="/job/">Job</a></span> | ||
| 24 | </nav> | ||
| 25 | <?php echo $this->content; ?> | ||
| 26 | </main> | ||
| 27 | |||
| 28 | <footer> | ||
| 29 | <p><a href="https://sr.ht/~cmc/hn/">Source Code</a></p> | ||
| 30 | <p>Copyright © 2023 - <?php echo $this->current_year; ?></p> | ||
| 31 | </footer> | ||
| 32 | |||
| 33 | </body> | ||
| 34 | |||
| 35 | </html> | ||
src/View/class-template.php deleted −58
| @@ -1,58 +0,0 @@ | |||
| 1 | <?php | ||
| 2 | |||
| 3 | namespace HN\View; | ||
| 4 | |||
| 5 | /** | ||
| 6 | * Template View | ||
| 7 | * | ||
| 8 | * @author cmc <hello@cleberg.net> | ||
| 9 | */ | ||
| 10 | class Template | ||
| 11 | { | ||
| 12 | /** | ||
| 13 | * @var string | ||
| 14 | */ | ||
| 15 | private $canonical_url; | ||
| 16 | /** | ||
| 17 | * @var string | ||
| 18 | */ | ||
| 19 | private $description; | ||
| 20 | /** | ||
| 21 | * @var string | ||
| 22 | */ | ||
| 23 | private $title; | ||
| 24 | /** | ||
| 25 | * @var string | ||
| 26 | */ | ||
| 27 | private $content; | ||
| 28 | /** | ||
| 29 | * @var false|string | ||
| 30 | */ | ||
| 31 | private $current_year; | ||
| 32 | |||
| 33 | public function __construct(string $canonical_url, string $page_description, string $page_title, string $content_col) { | ||
| 34 | $this->canonical_url = $canonical_url; | ||
| 35 | $this->description = $page_description; | ||
| 36 | $this->title = $page_title; | ||
| 37 | $this->content = $content_col; | ||
| 38 | $this->current_year = date("Y"); | ||
| 39 | } | ||
| 40 | |||
| 41 | public function echo_template(string $template_file) { | ||
| 42 | // Get the template file | ||
| 43 | $page = file_get_contents($template_file); | ||
| 44 | |||
| 45 | // Replace the template variables | ||
| 46 | $page = str_replace('{page_title}', $this->title, $page); | ||
| 47 | $page = str_replace('{page_description}', $this->description, $page); | ||
| 48 | $page = str_replace('{canonical_url}', $this->canonical_url, $page); | ||
| 49 | $page = str_replace('{content}', $this->content, $page); | ||
| 50 | $page = str_replace('{current_year}', $this->current_year, $page); | ||
| 51 | |||
| 52 | // Echo the filled-out template | ||
| 53 | echo $page; | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | // EOF | ||
| 58 | |||
static/styles.css +164 −23
| @@ -1,43 +1,184 @@ | |||
| 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ | 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ |
| 2 | button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none} | 2 | button, hr, input { |
| 3 | overflow: visible | ||
| 4 | } | ||
| 5 | |||
| 6 | progress, sub, sup { | ||
| 7 | vertical-align: baseline | ||
| 8 | } | ||
| 9 | |||
| 10 | [type=checkbox], [type=radio], legend { | ||
| 11 | box-sizing: border-box; | ||
| 12 | padding: 0 | ||
| 13 | } | ||
| 14 | |||
| 15 | html { | ||
| 16 | line-height: 1.15; | ||
| 17 | -webkit-text-size-adjust: 100% | ||
| 18 | } | ||
| 19 | |||
| 20 | body { | ||
| 21 | margin: 0 | ||
| 22 | } | ||
| 23 | |||
| 24 | details, main { | ||
| 25 | display: block | ||
| 26 | } | ||
| 27 | |||
| 28 | h1 { | ||
| 29 | font-size: 2em; | ||
| 30 | margin: .67em 0 | ||
| 31 | } | ||
| 32 | |||
| 33 | hr { | ||
| 34 | box-sizing: content-box; | ||
| 35 | height: 0 | ||
| 36 | } | ||
| 37 | |||
| 38 | code, kbd, pre, samp { | ||
| 39 | font-family: monospace, monospace; | ||
| 40 | font-size: 1em | ||
| 41 | } | ||
| 42 | |||
| 43 | a { | ||
| 44 | background-color: transparent | ||
| 45 | } | ||
| 46 | |||
| 47 | abbr[title] { | ||
| 48 | border-bottom: none; | ||
| 49 | text-decoration: underline; | ||
| 50 | text-decoration: underline dotted | ||
| 51 | } | ||
| 52 | |||
| 53 | b, strong { | ||
| 54 | font-weight: bolder | ||
| 55 | } | ||
| 56 | |||
| 57 | small { | ||
| 58 | font-size: 80% | ||
| 59 | } | ||
| 60 | |||
| 61 | sub, sup { | ||
| 62 | font-size: 75%; | ||
| 63 | line-height: 0; | ||
| 64 | position: relative | ||
| 65 | } | ||
| 66 | |||
| 67 | sub { | ||
| 68 | bottom: -.25em | ||
| 69 | } | ||
| 70 | |||
| 71 | sup { | ||
| 72 | top: -.5em | ||
| 73 | } | ||
| 74 | |||
| 75 | img { | ||
| 76 | border-style: none | ||
| 77 | } | ||
| 78 | |||
| 79 | button, input, optgroup, select, textarea { | ||
| 80 | font-family: inherit; | ||
| 81 | font-size: 100%; | ||
| 82 | line-height: 1.15; | ||
| 83 | margin: 0 | ||
| 84 | } | ||
| 85 | |||
| 86 | button, select { | ||
| 87 | text-transform: none | ||
| 88 | } | ||
| 89 | |||
| 90 | [type=button], [type=reset], [type=submit], button { | ||
| 91 | -webkit-appearance: button | ||
| 92 | } | ||
| 93 | |||
| 94 | [type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner { | ||
| 95 | border-style: none; | ||
| 96 | padding: 0 | ||
| 97 | } | ||
| 98 | |||
| 99 | [type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring { | ||
| 100 | outline: ButtonText dotted 1px | ||
| 101 | } | ||
| 102 | |||
| 103 | fieldset { | ||
| 104 | padding: .35em .75em .625em | ||
| 105 | } | ||
| 106 | |||
| 107 | legend { | ||
| 108 | color: inherit; | ||
| 109 | display: table; | ||
| 110 | max-width: 100%; | ||
| 111 | white-space: normal | ||
| 112 | } | ||
| 113 | |||
| 114 | textarea { | ||
| 115 | overflow: auto | ||
| 116 | } | ||
| 117 | |||
| 118 | [type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button { | ||
| 119 | height: auto | ||
| 120 | } | ||
| 121 | |||
| 122 | [type=search] { | ||
| 123 | -webkit-appearance: textfield; | ||
| 124 | outline-offset: -2px | ||
| 125 | } | ||
| 126 | |||
| 127 | [type=search]::-webkit-search-decoration { | ||
| 128 | -webkit-appearance: none | ||
| 129 | } | ||
| 130 | |||
| 131 | ::-webkit-file-upload-button { | ||
| 132 | -webkit-appearance: button; | ||
| 133 | font: inherit | ||
| 134 | } | ||
| 135 | |||
| 136 | summary { | ||
| 137 | display: list-item | ||
| 138 | } | ||
| 139 | |||
| 140 | [hidden], template { | ||
| 141 | display: none | ||
| 142 | } | ||
| 3 | 143 | ||
| 4 | /* custom css */ | 144 | /* custom css */ |
| 5 | body { | 145 | body { |
| 6 | padding: 1rem; | 146 | padding: 1rem; |
| 7 | font-family: system-ui, sans-serif; | 147 | font-family: system-ui, sans-serif; |
| 148 | max-width: 40em; | ||
| 8 | } | 149 | } |
| 9 | 150 | ||
| 10 | body > main > div { | 151 | body > main > div { |
| 11 | margin-bottom: 1rem; | 152 | margin-bottom: 1rem; |
| 12 | } | 153 | } |
| 13 | 154 | ||
| 14 | body > main > div > p { | 155 | body > main > div > p { |
| 15 | margin-top: 0.5rem; | 156 | margin-top: 0.5rem; |
| 16 | } | 157 | } |
| 17 | 158 | ||
| 18 | a { | 159 | a { |
| 19 | text-decoration: none; | 160 | text-decoration: none; |
| 20 | } | 161 | } |
| 21 | 162 | ||
| 22 | footer { | 163 | footer { |
| 23 | border-top: 1px solid black; | 164 | border-top: 1px solid black; |
| 24 | } | 165 | } |
| 25 | 166 | ||
| 26 | @media (prefers-color-scheme: dark) { | 167 | @media (prefers-color-scheme: dark) { |
| 27 | body { | 168 | body { |
| 28 | background-color: #000; | 169 | background-color: #000; |
| 29 | color: #ccc; | 170 | color: #ccc; |
| 30 | } | 171 | } |
| 31 | 172 | ||
| 32 | h1,h2,h3,h4,h5,h6 { | 173 | h1, h2, h3, h4, h5, h6 { |
| 33 | color: #fff; | 174 | color: #fff; |
| 34 | } | 175 | } |
| 35 | 176 | ||
| 36 | a,a:hover,a:visited { | 177 | a, a:hover, a:visited { |
| 37 | color: #0f0; | 178 | color: #0f0; |
| 38 | } | 179 | } |
| 39 | 180 | ||
| 40 | footer { | 181 | footer { |
| 41 | border-color: #ccc; | 182 | border-color: #ccc; |
| 42 | } | 183 | } |
| 43 | } | 184 | } |
static/styles.min.css +1 −1
| @@ -1 +1 @@ | |||
| 1 | /*!normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css*/button,hr,input{overflow:visible}progress,sub,sup{vertical-align:baseline}[type=checkbox],[type=radio],legend{box-sizing:border-box;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}details,main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}a{background-color:initial}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:ButtonText dotted 1px}fieldset{padding:.35em .75em .625em}legend{color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}[hidden],template{display:none}body{padding:1rem;font-family:system-ui,sans-serif}body>main>div{margin-bottom:1rem}body>main>div>p{margin-top:.5rem}a{text-decoration:none}footer{border-top:1px solid #000}@media(prefers-color-scheme:dark){body{background-color:#000;color:#ccc}h1,h2,h3,h4,h5,h6{color:#fff}a,a:hover,a:visited{color:#0f0}footer{border-color:#ccc}}\ No newline at end of file | 1 | button, hr, input {overflow: visible }progress, sub, sup {vertical-align: baseline }[type=checkbox], [type=radio], legend {box-sizing: border-box;padding: 0 }html {line-height: 1.15;-webkit-text-size-adjust: 100% }body {margin: 0 }details, main {display: block }h1 {font-size: 2em;margin: .67em 0 }hr {box-sizing: content-box;height: 0 }code, kbd, pre, samp {font-family: monospace, monospace;font-size: 1em }a {background-color: transparent }abbr[title] {border-bottom: none;text-decoration: underline;text-decoration: underline dotted }b, strong {font-weight: bolder }small {font-size: 80% }sub, sup {font-size: 75%;line-height: 0;position: relative }sub {bottom: -.25em }sup {top: -.5em }img {border-style: none }button, input, optgroup, select, textarea {font-family: inherit;font-size: 100%;line-height: 1.15;margin: 0 }button, select {text-transform: none }[type=button], [type=reset], [type=submit], button {-webkit-appearance: button }[type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner {border-style: none;padding: 0 }[type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring {outline: ButtonText dotted 1px }fieldset {padding: .35em .75em .625em }legend {color: inherit;display: table;max-width: 100%;white-space: normal }textarea {overflow: auto }[type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button {height: auto }[type=search] {-webkit-appearance: textfield;outline-offset: -2px }[type=search]::-webkit-search-decoration {-webkit-appearance: none }::-webkit-file-upload-button {-webkit-appearance: button;font: inherit }summary {display: list-item }[hidden], template {display: none }body {padding: 1rem;font-family: system-ui, sans-serif;max-width: 40em;}body > main > div {margin-bottom: 1rem;}body > main > div > p {margin-top: 0.5rem;}a {text-decoration: none;}footer {border-top: 1px solid black;}.user-submission {border-bottom: 1px solid black;}@media (prefers-color-scheme: dark) {body {background-color: #000;color: #ccc;}h1, h2, h3, h4, h5, h6 {color: #fff;}a, a:hover, a:visited {color: #0f0;}footer {border-color: #ccc;}.user-submission {border-color: white;}}\ No newline at end of file |
templates/template.html deleted −36
| @@ -1,36 +0,0 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | |||
| 4 | <head> | ||
| 5 | <title>{page_title}</title> | ||
| 6 | <meta charset="UTF-8"> | ||
| 7 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||
| 8 | <meta http-equiv="x-ua-compatible" content="ie=edge"> | ||
| 9 | <meta name="author" content="My Name"> | ||
| 10 | <meta name="description" content="{page_description}"> | ||
| 11 | <link rel="canonical" href="{canonical_url}"> | ||
| 12 | <!--<link rel="icon" href="/favicon.ico">--> | ||
| 13 | <link rel="stylesheet" href="/static/styles.min.css"> | ||
| 14 | </head> | ||
| 15 | |||
| 16 | <body> | ||
| 17 | <main id="main"> | ||
| 18 | <nav class="links"> | ||
| 19 | <span><a href="/">Top</a> · </span> | ||
| 20 | <span><a href="/best/">Best</a> ·</span> | ||
| 21 | <span><a href="/new/">New</a> ·</span> | ||
| 22 | <span><a href="/ask/">Ask</a> ·</span> | ||
| 23 | <span><a href="/show/">Show</a> ·</span> | ||
| 24 | <span><a href="/job/">Job</a></span> | ||
| 25 | </nav> | ||
| 26 | {content} | ||
| 27 | </main> | ||
| 28 | |||
| 29 | <footer> | ||
| 30 | <p><a href="https://sr.ht/~cmc/hn/">Source Code</a></p> | ||
| 31 | <p>Copyright © 2023 - {current_year}</p> | ||
| 32 | </footer> | ||
| 33 | |||
| 34 | </body> | ||
| 35 | |||
| 36 | </html> | ||