Commit 49efb238b8
Unsigned
Layout: unified · split
CODE_OF_CONDUCT.md +2 −2
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 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 | 6 | are inherent for this project: |
| 7 | 7 | |
| 8 | 8 | - Respect |
| @@ -11,5 +11,5 @@ are inherent for this project: | ||
| 11 | 11 | |
| 12 | 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 | 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 | 11 | ## Reporting Bugs |
| 12 | 12 | |
| 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 | |
| 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 | |
| 15 | 15 | reported, emails will need to be exchanged before anyone can look into the bug. |
README.md +18 −16
| @@ -1,18 +1,18 @@ | ||
| 1 | 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 | 4 | News, focusing on privacy and simplicity. |
| 5 | 5 | |
| 6 | 6 | ## Getting Started |
| 7 | 7 | |
| 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 | |
| 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 | |
| 10 | 10 | how to deploy the project on a live system. |
| 11 | 11 | |
| 12 | 12 | ### Prerequisites |
| 13 | 13 | |
| 14 | 14 | - A web server (e.g., Nginx or Apache) |
| 15 | - PHP | |
| 15 | - PHP (>= v8.0) | |
| 16 | 16 | - Optional: minify |
| 17 | 17 | |
| 18 | 18 | ### Installing |
| @@ -20,7 +20,7 @@ how to deploy the project on a live system. | ||
| 20 | 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 | 26 | Clone the repo: |
| @@ -38,11 +38,11 @@ minify -o static/styles.min.css static/styles.css | ||
| 38 | 38 | |
| 39 | 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 | 42 | or tools required. |
| 43 | 43 | |
| 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` | |
| 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` | |
| 46 | 46 | error. |
| 47 | 47 | |
| 48 | 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 | 59 | the directory you're serving the PHP file from: |
| 60 | 60 | |
| 61 | 61 | ```conf |
| @@ -66,12 +66,13 @@ FallbackResource /index.php | ||
| 66 | 66 | |
| 67 | 67 | * [PHP](https://www.php.net/) - The scripting language |
| 68 | 68 | * [HTML](https://html.spec.whatwg.org/multipage/) - The markup language |
| 69 | * [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to | |
| 70 | minify CSS | |
| 69 | * [minify](https://github.com/tdewolff/minify/tree/master/cmd/minify) - Used to | |
| 70 | minify CSS | |
| 71 | 71 | |
| 72 | 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 | 76 | conduct, and the process for submitting pull requests to us. |
| 76 | 77 | |
| 77 | 78 | ### TODO |
| @@ -79,9 +80,10 @@ conduct, and the process for submitting pull requests to us. | ||
| 79 | 80 | A scratch pad of ideas that may be useful to implement: |
| 80 | 81 | |
| 81 | 82 | - [x] Add minimal CSS. |
| 82 | - [~] Add functionality to view a user's profile. | |
| 83 | - [ ] Add functionality to view item-specific page with comments. | |
| 84 | - [ ] Add functionality to load more items or paginate? | |
| 83 | - [x] Add functionality to view a user's profile. | |
| 84 | - [ ] Add functionality to view item-specific page with comments (`ConstructStoryDiscussion`). | |
| 85 | - [ ] Add functionality to handle polls (`ConstructPoll` & `ConstructPollOpt`). | |
| 86 | - [ ] Add functionality to load more items or paginate. | |
| 85 | 87 | |
| 86 | 88 | ## Versioning |
| 87 | 89 | |
| @@ -93,5 +95,5 @@ This project currently doesn't use versioning. See the git log instead. | ||
| 93 | 95 | |
| 94 | 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 | 99 | [LICENSE.md](./LICENSE.md) file for details. |
index.php +6 −232
| @@ -1,236 +1,10 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | |
| 3 | $full_domain = 'https://hn.cleberg.net'; | |
| 4 | $path = ltrim($_SERVER['REQUEST_URI'], '/'); | |
| 5 | $elements = explode('/', $path); | |
| 3 | require_once 'src/Controller/RouteController.php'; | |
| 6 | 4 | |
| 7 | if (empty($elements[0])) { | |
| 8 | $html_output = get_stories( | |
| 9 | 'https://hacker-news.firebaseio.com/v0/topstories.json?limitToFirst=10&orderBy="$key"', | |
| 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; | |
| 5 | $GLOBALS['full_domain'] = 'https://hn.cleberg.net'; | |
| 6 | $GLOBALS['author_name'] = 'cmc'; | |
| 7 | $GLOBALS['site_title'] = 'hn'; | |
| 32 | 8 | |
| 33 | case 'best': | |
| 34 | $html_output = get_stories( | |
| 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 | |
| 9 | $route = new HN\Controllers\RouteController($_SERVER['REQUEST_URI']); | |
| 10 | $route->routeUser(); | |
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 | 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 | 144 | /* custom css */ |
| 5 | 145 | body { |
| 6 | padding: 1rem; | |
| 7 | font-family: system-ui, sans-serif; | |
| 146 | padding: 1rem; | |
| 147 | font-family: system-ui, sans-serif; | |
| 148 | max-width: 40em; | |
| 8 | 149 | } |
| 9 | 150 | |
| 10 | 151 | body > main > div { |
| 11 | margin-bottom: 1rem; | |
| 152 | margin-bottom: 1rem; | |
| 12 | 153 | } |
| 13 | 154 | |
| 14 | 155 | body > main > div > p { |
| 15 | margin-top: 0.5rem; | |
| 156 | margin-top: 0.5rem; | |
| 16 | 157 | } |
| 17 | 158 | |
| 18 | 159 | a { |
| 19 | text-decoration: none; | |
| 160 | text-decoration: none; | |
| 20 | 161 | } |
| 21 | 162 | |
| 22 | 163 | footer { |
| 23 | border-top: 1px solid black; | |
| 164 | border-top: 1px solid black; | |
| 24 | 165 | } |
| 25 | 166 | |
| 26 | 167 | @media (prefers-color-scheme: dark) { |
| 27 | body { | |
| 28 | background-color: #000; | |
| 29 | color: #ccc; | |
| 30 | } | |
| 31 | ||
| 32 | h1,h2,h3,h4,h5,h6 { | |
| 33 | color: #fff; | |
| 34 | } | |
| 35 | ||
| 36 | a,a:hover,a:visited { | |
| 37 | color: #0f0; | |
| 38 | } | |
| 39 | ||
| 40 | footer { | |
| 41 | border-color: #ccc; | |
| 42 | } | |
| 168 | body { | |
| 169 | background-color: #000; | |
| 170 | color: #ccc; | |
| 171 | } | |
| 172 | ||
| 173 | h1, h2, h3, h4, h5, h6 { | |
| 174 | color: #fff; | |
| 175 | } | |
| 176 | ||
| 177 | a, a:hover, a:visited { | |
| 178 | color: #0f0; | |
| 179 | } | |
| 180 | ||
| 181 | footer { | |
| 182 | border-color: #ccc; | |
| 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> | |