Deprecated: Tightenco\Collect\Support\Arr::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Arr.php on line 188

Deprecated: Tightenco\Collect\Support\Arr::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Arr.php on line 217

Deprecated: Tightenco\Collect\Support\Collection::filter(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 379

Deprecated: Tightenco\Collect\Support\Collection::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 397

Deprecated: Tightenco\Collect\Support\Collection::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Collection.php on line 741

Deprecated: Tightenco\Collect\Support\Traits\EnumeratesValues::times(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/EnumeratesValues.php on line 162

Deprecated: Tightenco\Collect\Support\Traits\EnumeratesValues::whenEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/EnumeratesValues.php on line 482

Deprecated: Tightenco\Collect\Support\Traits\EnumeratesValues::whenNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/EnumeratesValues.php on line 496

Deprecated: Tightenco\Collect\Support\Traits\EnumeratesValues::unlessEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/EnumeratesValues.php on line 510

Deprecated: Tightenco\Collect\Support\Traits\EnumeratesValues::unlessNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/EnumeratesValues.php on line 524

Deprecated: Tightenco\Collect\Support\Traits\Conditionable::when(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/Conditionable.php on line 21

Deprecated: Tightenco\Collect\Support\Traits\Conditionable::when(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/Conditionable.php on line 21

Deprecated: Tightenco\Collect\Support\Traits\Conditionable::unless(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/Conditionable.php on line 53

Deprecated: Tightenco\Collect\Support\Traits\Conditionable::unless(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Traits/Conditionable.php on line 53

Deprecated: Tightenco\Collect\Support\Enumerable::times(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 40

Deprecated: Tightenco\Collect\Support\Enumerable::filter(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 298

Deprecated: Tightenco\Collect\Support\Enumerable::when(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 310

Deprecated: Tightenco\Collect\Support\Enumerable::when(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 310

Deprecated: Tightenco\Collect\Support\Enumerable::whenEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 321

Deprecated: Tightenco\Collect\Support\Enumerable::whenNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 332

Deprecated: Tightenco\Collect\Support\Enumerable::unless(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 344

Deprecated: Tightenco\Collect\Support\Enumerable::unlessEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 355

Deprecated: Tightenco\Collect\Support\Enumerable::unlessNotEmpty(): Implicitly marking parameter $default as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 366

Deprecated: Tightenco\Collect\Support\Enumerable::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 478

Deprecated: Tightenco\Collect\Support\Enumerable::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/Enumerable.php on line 620

Deprecated: Tightenco\Collect\Support\LazyCollection::filter(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/LazyCollection.php on line 430

Deprecated: Tightenco\Collect\Support\LazyCollection::first(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/LazyCollection.php on line 454

Deprecated: Tightenco\Collect\Support\LazyCollection::last(): Implicitly marking parameter $callback as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/tightenco/collect/src/Collect/Support/LazyCollection.php on line 734

Deprecated: DI\Bridge\Slim\Bridge::create(): Implicitly marking parameter $container as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/php-di/slim-bridge/src/Bridge.php on line 25

Deprecated: ParsedownExtra::blockSetextHeader(): Implicitly marking parameter $Block as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/erusev/parsedown-extra/ParsedownExtra.php on line 241

Deprecated: Parsedown::blockSetextHeader(): Implicitly marking parameter $Block as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/erusev/parsedown/Parsedown.php on line 715

Deprecated: Parsedown::blockTable(): Implicitly marking parameter $Block as nullable is deprecated, the explicit nullable type must be used instead in /usr/share/nginx/html/app/vendor/erusev/parsedown/Parsedown.php on line 853
PK-G.ZLICENSESV GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . PK=gMMPK- /ZNOTESVNEW FEATURE: You can now forward news articles to an e-mail address of your choice. Go to Settings to add an e-mail address and enable. PKPK-%Z README.mdSV# tesla-cloud ## About JavaScript-based site with information and links intended for use on an in-car browser. Currently focused on Teslas, but could be adapted for other vehicles with a browser. ## Demo The main branch is generally running at . ## Screenshots ### Weather forecast ![Screenshot 2025-03-05 002145](https://github.com/user-attachments/assets/b72e9585-fad5-41ee-b955-06d146ebae27) ### News feed ![Screenshot 2025-03-05 002224](https://github.com/user-attachments/assets/569b3b99-4d42-4b6a-b86b-6cc739381e8d) ### Waze integration ![Screenshot 2025-03-05 002326](https://github.com/user-attachments/assets/56618b10-b545-474b-a918-c515212d76dc) PK` ߮PK-G.Z SECURITY.mdSV# Security Policy ## Supported Versions Only the current version is supported. ## Reporting a Vulnerability Please send all security reports to admin@birgefuller.com PKi[PK-jZapp.jsSV// Imports import { highlightUpdate, srcUpdate, testMode, updateTimeZone, GEONAMES_USERNAME } from './common.js'; import { PositionSimulator } from './location.js'; import { attemptLogin, leaveSettings, settings } from './settings.js'; import { fetchPremiumWeatherData, SAT_URLS, forecastDataPrem } from './wx.js'; import { updateNetworkInfo, updatePingChart, startPingTest } from './net.js'; import { markAllNewsAsRead, startNewsTimeUpdates, stopNewsTimeUpdates } from './news.js'; // Parameters const LATLON_UPDATE_INTERVAL = 2; // seconds const UPDATE_DISTANCE_THRESHOLD = 2500; // meters const UPDATE_TIME_THRESHOLD = 10; // minutes const WX_DISTANCE_THRESHOLD = 25000; // meters const WX_TIME_THRESHOLD = 60; // minutes const MAX_SPEED = 50; // Maximum speed for radar display (mph) const MIN_GPS_UPDATE_INTERVAL = 1000; // ms - minimum time between updates // Module variables let currentSection = null; // Track the current section let lastUpdate = 0; // Timestamp of last location update let lat = null; let long = null; let alt = null; let acc = null; let speed = null; let lastUpdateLat = null; let lastUpdateLong = null; let lastKnownHeading = null; let lastWxUpdate = 0; let lastWxUpdateLat = null; let lastWxUpdateLong = null; let neverUpdatedLocation = true; let radarContext = null; let gpsIntervalId = null; let lastGPSUpdate = 0; let networkInfoUpdated = false; // Track if network info has been updated const positionSimulator = new PositionSimulator(); // TODO: only create if needed // Function to calculate the distance between two coordinates function calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371e3; // Earth's radius in meters const φ1 = lat1 * Math.PI / 180; const φ2 = lat2 * Math.PI / 180; const Δφ = (lat2 - lat1) * Math.PI / 180; const Δλ = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; // returns distance in meters } // Function to fetch city data based on latitude and longitude function fetchCityData(lat, long) { fetch(`https://secure.geonames.org/findNearbyPlaceNameJSON?lat=${lat}&lng=${long}&username=${GEONAMES_USERNAME}`) .then(response => response.json()) .then(cityData => { const place = cityData.geonames && cityData.geonames[0]; highlightUpdate('city', place ? (place.name || 'N/A') : 'N/A'); // Highlight the city update highlightUpdate('state', place ? (place.adminName1 || 'N/A') : 'N/A'); // Highlight the state update }) .catch(error => { console.error('Error fetching city data:', error); }); } // Function to fetch nearby Wikipedia data based on coordinates async function fetchLandmarkData(lat, long) { console.log('Fetching Wikipedia data...'); const url = `https://secure.geonames.org/findNearbyWikipediaJSON?lat=${lat}&lng=${long}&maxRows=9&username=${GEONAMES_USERNAME}`; try { const response = await fetch(url); const data = await response.json(); const landmarkDiv = document.getElementById('landmark-items'); if (data.geonames && data.geonames.length > 0) { let html = '
    '; data.geonames.forEach(article => { const pageUrl = article.wikipediaUrl.startsWith('http') ? article.wikipediaUrl : 'http://' + article.wikipediaUrl; html += `
  • ${article.title}: ${article.summary}
  • `; }); html += '
'; landmarkDiv.innerHTML = html; } else { landmarkDiv.innerHTML = '

No nearby landmarks found.

'; } } catch (error) { console.error('Error fetching Wikipedia data:', error); document.getElementById('landmark-items').innerHTML = '

Error loading landmark data.

'; } } // Function to initialize the radar display function initializeRadar() { const canvas = document.getElementById('radarDisplay'); if (canvas) { radarContext = canvas.getContext('2d'); // Initial draw updateWindage(0, null, 0, 0); } } // Function to update windage on the radar display function updateWindage(vehicleSpeed, vehicleHeading, windSpeed, windDirection) { const canvas = radarContext.canvas; const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = Math.min(centerX, centerY) - 8; // Clear canvas with transparent background radarContext.clearRect(0, 0, canvas.width, canvas.height); // Draw circular background radarContext.beginPath(); radarContext.arc(centerX, centerY, radius, 0, 2 * Math.PI); radarContext.strokeStyle = '#666'; radarContext.lineWidth = 1; radarContext.stroke(); // Draw concentric circles with speed labels for (let i = 1; i <= 4; i++) { const currentRadius = (radius * i) / 4; radarContext.beginPath(); radarContext.arc(centerX, centerY, currentRadius, 0, 2 * Math.PI); radarContext.strokeStyle = '#666'; radarContext.setLineDash([2, 2]); radarContext.stroke(); radarContext.setLineDash([]); // Add speed label const speedLabel = Math.round((MAX_SPEED * i) / 4); radarContext.fillStyle = '#666'; radarContext.font = '10px Inter'; radarContext.textAlign = 'right'; radarContext.fillText(speedLabel, centerX - 5, centerY - currentRadius + 12); } // Draw cardinal direction lines radarContext.beginPath(); radarContext.moveTo(centerX, centerY - radius); radarContext.lineTo(centerX, centerY + radius); radarContext.moveTo(centerX - radius, centerY); radarContext.lineTo(centerX + radius, centerY); radarContext.strokeStyle = '#666'; radarContext.stroke(); // Draw direction labels with dark gray background for visibility radarContext.fillStyle = '#666'; radarContext.font = '12px Inter'; radarContext.textAlign = 'center'; radarContext.textBaseline = 'middle'; // Position labels with proper spacing and background const labelOffset = radius - 5; function drawLabel(text, x, y) { const padding = 4; const metrics = radarContext.measureText(text); radarContext.fillStyle = '#666'; radarContext.fillText(text, x, y); } drawLabel('FWD', centerX, centerY - labelOffset); drawLabel('AFT', centerX, centerY + labelOffset); drawLabel('RT', centerX + labelOffset, centerY); drawLabel('LT', centerX - labelOffset, centerY); // Get the Tesla blue color from CSS const teslaBlue = getComputedStyle(document.documentElement).getPropertyValue('--tesla-blue').trim(); // Helper function to draw arrow function drawArrow(fromX, fromY, toX, toY, color, headLength = 9) { const angle = Math.atan2(toY - fromY, toX - fromX); const headAngle = Math.PI / 6; // 30 degrees radarContext.beginPath(); radarContext.moveTo(fromX, fromY); radarContext.lineTo(toX, toY); // Draw the arrow head radarContext.lineTo( toX - headLength * Math.cos(angle - headAngle), toY - headLength * Math.sin(angle - headAngle) ); radarContext.moveTo(toX, toY); radarContext.lineTo( toX - headLength * Math.cos(angle + headAngle), toY - headLength * Math.sin(angle + headAngle) ); radarContext.strokeStyle = color; radarContext.lineWidth = 3; radarContext.stroke(); } // Calculate headwind and crosswind components and display on radar let headWind = null; let crossWind = null; if (vehicleHeading && windDirection) { // Threshold for meaningful motion const windAngle = windDirection - vehicleHeading; // car frame const windAngleRad = (90 - windAngle) * Math.PI / 180; // Wind vector components in global frame const windX = windSpeed * Math.cos(windAngleRad); const windY = windSpeed * Math.sin(windAngleRad); // Sum the vectors to get relative wind (for radar plot) const relativeWindX = windX; const relativeWindY = windY; headWind = -windY; // Will be negative if a tailwind crossWind = windX; // Will be positive if from the left const windScale = radius / MAX_SPEED; const relativeWindXPlot = centerX + relativeWindX * windScale; const relativeWindYPlot = centerY - relativeWindY * windScale; drawArrow(centerX, centerY, relativeWindXPlot, relativeWindYPlot, teslaBlue); } // Update the wind component displays with proper units if (headWind !== null) { if (!settings || settings["imperial-units"]) { document.getElementById('headwind').innerText = Math.abs(Math.round(headWind)); } else { // Convert mph to m/s (1 mph ≈ 0.44704 m/s) document.getElementById('headwind').innerText = Math.abs(Math.round(headWind * 0.44704)); } document.getElementById('headwind-arrow').innerHTML = (headWind > 0 ? '▼' : '▲'); // down/up filled triangles // Change the label to TAILWIND when headWind is negative and use appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = (headWind < 0) ? "TAILWIND (MPH)" : "HEADWIND (MPH)"; } else { document.getElementById('headwind-label').innerText = (headWind < 0) ? "TAILWIND (M/S)" : "HEADWIND (M/S)"; } } else { document.getElementById('headwind').innerText = '--'; document.getElementById('headwind-arrow').innerHTML = ''; // Set label with appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = "HEADWIND (MPH)"; } else { document.getElementById('headwind-label').innerText = "HEADWIND (M/S)"; } } if (crossWind !== null) { if (!settings || settings["imperial-units"]) { document.getElementById('crosswind').innerText = Math.abs(Math.round(crossWind)); } else { // Convert mph to m/s document.getElementById('crosswind').innerText = Math.abs(Math.round(crossWind * 0.44704)); } document.getElementById('crosswind-arrow').innerHTML = (crossWind >= 0 ? '▶' : '◀'); // right/left triangles } else { document.getElementById('crosswind').innerText = '--'; document.getElementById('crosswind-arrow').innerHTML = ''; } // Set label with appropriate units if (!settings || settings["imperial-units"]) { document.getElementById('crosswind-label').innerText = "CROSSWIND (MPH)"; } else { document.getElementById('crosswind-label').innerText = "CROSSWIND (M/S)"; } } // Function to update location-dependent data async function updateLocationData(lat, long) { console.log('Updating location dependent data for (', lat, ', ', long, ')'); neverUpdatedLocation = false; // Fire off API requests for external data // updateTimeZone(lat, long); fetchCityData(lat, long); // Update connectivity data iff the Network section is visible // const networkSection = document.getElementById("network"); // if (networkSection.style.display === "block") { // console.log('Updating connectivity data...'); // updateNetworkInfo(); // } // Update Wikipedia data iff the Landmarks section is visible const locationSection = document.getElementById("landmarks"); if (locationSection.style.display === "block") { console.log('Updating Wikipedia data...'); fetchLandmarkData(lat, long); } } // Function to determine if short-range data should be updated function shouldUpdateShortRangeData() { if (neverUpdatedLocation || !lastUpdateLat || !lastUpdateLong) { return true; } const now = Date.now(); const timeSinceLastUpdate = (now - lastUpdate) / (1000 * 60); // Convert to minutes const distance = calculateDistance(lat, long, lastUpdateLat, lastUpdateLong); return distance >= UPDATE_DISTANCE_THRESHOLD || timeSinceLastUpdate >= UPDATE_TIME_THRESHOLD; } // Function to determine if long-range data should be updated function shouldUpdateLongRangeData() { // Check if we've never updated weather data if (lastWxUpdate === 0 || lastWxUpdateLat === null || lastWxUpdateLong === null) { return true; } // Check time threshold using WX_TIME_THRESHOLD constant const now = Date.now(); const timeSinceLastUpdate = now - lastWxUpdate; if (timeSinceLastUpdate >= WX_TIME_THRESHOLD * 60 * 1000) { // Convert minutes to milliseconds return true; } // Check distance threshold using WX_DISTANCE_THRESHOLD constant if (lat !== null && long !== null) { const distance = calculateDistance(lat, long, lastWxUpdateLat, lastWxUpdateLong); if (distance >= WX_DISTANCE_THRESHOLD) { // Use constant for meters return true; } } // No need to update weather data return false; } // Function to handle position updates from GPS function handlePositionUpdate(position) { lat = position.coords.latitude; long = position.coords.longitude; alt = position.coords.altitude; acc = position.coords.accuracy; speed = position.coords.speed / 0.44704; // Convert m/s to mph if (position.coords.heading) { lastKnownHeading = position.coords.heading; } // Update GPS status indicator with color gradient based on accuracy const gpsStatusElement = document.getElementById('gps-status'); if (gpsStatusElement) { if (lat === null || long === null) { // Use CSS variable for unavailable GPS gpsStatusElement.style.color = 'var(--status-unavailable)'; gpsStatusElement.title = 'GPS Unavailable'; } else { // Interpolate between yellow and green based on accuracy const maxAccuracy = 50; // Yellow threshold const minAccuracy = 1; // Green threshold // Clamp accuracy between min and max thresholds const clampedAcc = Math.min(Math.max(acc, minAccuracy), maxAccuracy); // Calculate interpolation factor (0 = yellow, 1 = green) const factor = 1 - (clampedAcc - minAccuracy) / (maxAccuracy - minAccuracy); if (factor < 0.5) { gpsStatusElement.style.color = 'var(--status-poor)'; } else { gpsStatusElement.style.color = 'var(--status-good)'; } gpsStatusElement.title = `GPS Accuracy: ${Math.round(acc)}m`; } } // Update radar display with current speed and heading if nav section is visible const navigationSection = document.getElementById("navigation"); if (navigationSection.style.display === "block") { // Update heading displays if (lastKnownHeading) { document.getElementById('heading').innerText = Math.round(lastKnownHeading) + '°'; if (forecastDataPrem && forecastDataPrem.current) { const windSpeedMPH = Math.min((forecastDataPrem.current.wind_speed * 2.237), MAX_SPEED); const windDir = forecastDataPrem.current.wind_deg; updateWindage(speed, lastKnownHeading, windSpeedMPH, windDir); } else { updateWindage(speed, lastKnownHeading, 0, 0); } } else { document.getElementById('heading').innerText = '--'; updateWindage(0, null, 0, 0); } // Update display values with proper units if (alt !== null) { if (!settings || settings["imperial-units"]) { // Convert meters to feet document.getElementById('altitude').innerText = Math.round(alt * 3.28084); document.getElementById('altitude-unit').innerText = 'FT'; } else { document.getElementById('altitude').innerText = Math.round(alt); document.getElementById('altitude-unit').innerText = 'M'; } } else { document.getElementById('altitude').innerText = '--'; } document.getElementById('accuracy').innerText = acc ? Math.round(acc) + ' m' : '--'; // Update headwind/crosswind labels if (!settings || settings["imperial-units"]) { document.getElementById('headwind-label').innerText = document.getElementById('headwind-label').innerText.replace("(MPH)", "(MPH)"); document.querySelector('.stat-box:nth-child(4) .stat-label').innerText = document.querySelector('.stat-box:nth-child(4) .stat-label').innerText.replace("(MPH)", "(MPH)"); } else { document.getElementById('headwind-label').innerText = document.getElementById('headwind-label').innerText.replace("(MPH)", "(M/S)"); document.querySelector('.stat-box:nth-child(4) .stat-label').innerText = document.querySelector('.stat-box:nth-child(4) .stat-label').innerText.replace("(MPH)", "(M/S)"); } } // Short distance updates if (shouldUpdateShortRangeData()) { updateLocationData(lat, long); fetchPremiumWeatherData(lat, long); lastUpdateLat = lat; lastUpdateLong = long; lastUpdate = Date.now(); } // Long distance updates if (shouldUpdateLongRangeData()) { updateTimeZone(lat, long); lastWxUpdateLat = lat; lastWxUpdateLong = long; lastWxUpdate = Date.now(); } } // Function to update GPS data function updateGPS() { if (!testMode) { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(handlePositionUpdate); } else { console.log('Geolocation is not supported by this browser.'); return false; } } else { // testing handlePositionUpdate(positionSimulator.getPosition()); } return true; } // Function to throttle GPS updates function throttledUpdateGPS() { const now = Date.now(); if (now - lastGPSUpdate >= MIN_GPS_UPDATE_INTERVAL) { lastGPSUpdate = now; updateGPS(); } else { console.log('Skipping rapid GPS update'); } } // Function to start the GPS updates function startGPSUpdates() { if (!gpsIntervalId) { if (updateGPS()) { // Call immediately and check if browser supports gpsIntervalId = setInterval(throttledUpdateGPS, 1000 * LATLON_UPDATE_INTERVAL); console.log('GPS updates started'); } } } // Function to stop the GPS updates function stopGPSUpdates() { if (gpsIntervalId) { clearInterval(gpsIntervalId); gpsIntervalId = null; console.log('GPS updates paused'); } } // Check for NOTE file and display if present function updateServerNote() { fetch('NOTE', { cache: 'no-store' }) .then(response => { if (!response.ok) { throw new Error('File not found.'); } return response.text(); }) .then(content => { // Sanitize the content to prevent XSS const sanitizedContent = document.createElement('div'); sanitizedContent.textContent = content; // Update the note paragraph with the sanitized content in italic const noteElement = document.getElementById('note'); noteElement.innerHTML = sanitizedContent.innerHTML; // Show the announcement section const announcementSection = document.getElementById('announcement'); if (announcementSection) { announcementSection.style.display = 'block'; } // Add notification dot to About section if it's not the current section const aboutSection = document.getElementById('about'); if (aboutSection && aboutSection.style.display !== 'block') { const aboutButton = document.querySelector('.section-button[onclick="showSection(\'about\')"]'); if (aboutButton) { aboutButton.classList.add('has-notification'); } } }) .catch(error => { console.log('No NOTE file available.'); // Ensure the announcement section is hidden const announcementSection = document.getElementById('announcement'); if (announcementSection) { announcementSection.style.display = 'none'; } }); } // Show git version from vers.php function updateVersion() { const versionElement = document.getElementById('version'); if (versionElement) { fetch('vers.php') .then(response => response.json()) .then(data => { const versionText = `${data.branch || 'unknown'}-${data.commit || 'unknown'}`; versionElement.innerHTML = versionText; }) .catch(error => { console.error('Error fetching version:', error); versionElement.innerHTML = 'Error loading version'; }); } } window.updateMapFrame = function () { // Normal mode - ensure iframe is visible and test mode message is hidden const teslaWazeContainer = document.querySelector('.teslawaze-container'); const iframe = teslaWazeContainer.querySelector('iframe'); let testModeMsg = teslaWazeContainer.querySelector('.test-mode-message'); if (!testMode) { if (settings["map-choice"] === 'waze') { srcUpdate("teslawaze", "https://teslawaze.azurewebsites.net/"); } else { srcUpdate("teslawaze", "https://abetterrouteplanner.com/"); } iframe.style.display = ''; if (testModeMsg) testModeMsg.style.display = 'none'; } else { // In test mode, replace TeslaWaze iframe with "TESTING MODE" message iframe.src = ""; iframe.style.display = 'none'; // Check if our test mode message already exists if (!testModeMsg) { // Create and add the test mode message testModeMsg = document.createElement('div'); testModeMsg.className = 'test-mode-message'; testModeMsg.style.cssText = 'display: flex; justify-content: center; align-items: center; height: 100%; font-size: 32px; font-weight: bold;'; testModeMsg.textContent = 'TESTING MODE'; teslaWazeContainer.appendChild(testModeMsg); } else { testModeMsg.style.display = 'flex'; } } } // Function to load an external URL in a new tab or frame window.loadExternalUrl = function (url, inFrame = false) { // Open external links in a new tab if (!inFrame) { window.open(url, '_blank'); return; } // Hide all sections first const sections = document.querySelectorAll('.section'); sections.forEach(section => { section.style.display = 'none'; }); // Get the external-site container const externalSite = document.getElementById('external-site'); // Clear any existing content externalSite.innerHTML = ''; // Create and load iframe const iframe = document.createElement('iframe'); iframe.setAttribute('allow', 'geolocation; fullscreen'); iframe.src = url; externalSite.appendChild(iframe); // Show the external site container externalSite.style.display = 'block'; // Flag the right frame as being in external mode const rightFrame = document.getElementById('rightFrame'); rightFrame.classList.add('external'); // Deactivate current section button // const activeButton = document.querySelector('.section-button.active'); // if (activeButton) { // activeButton.classList.remove('active'); // } } // Show a specific section and update URL - defined directly on window object window.showSection = function (sectionId) { const rightFrame = document.getElementById('rightFrame'); // Check if we're actually going anywhere if (currentSection === sectionId && !rightFrame.classList.contains('external')) { console.log(`Already in section: ${sectionId}`); return; } // Log the clicked section console.log(`Showing section: ${sectionId}`); // Update URL without page reload const url = new URL(window.location); url.searchParams.set('section', sectionId); window.history.pushState({}, '', url); // Shutdown external site if there is one const externalSite = document.getElementById('external-site'); if (rightFrame.classList.contains('external')) { // Hide the external site container externalSite.style.display = 'none'; // Clear any existing iframe content to prevent resource usage externalSite.innerHTML = ''; // Remove external mode flag rightFrame.classList.remove('external'); } // If we're leaving settings, handle any rss feed changes if (currentSection === 'settings') { leaveSettings(); } // Clear "new" markers from news items and clear unread flags from data if (currentSection === 'news') { const newNewsItems = document.querySelectorAll('.news-new'); newNewsItems.forEach(item => { item.classList.remove('news-new'); }); markAllNewsAsRead(); } // If switching to news section, clear the notification dot and start time updates if (sectionId === 'news') { const newsButton = document.querySelector('.section-button[onclick="showSection(\'news\')"]'); if (newsButton) { newsButton.classList.remove('has-notification'); } // Start the timer that updates the "time ago" displays startNewsTimeUpdates(); } else if (currentSection === 'news') { // If we're leaving the news section, stop the time updates stopNewsTimeUpdates(); } // If switching to about section, clear the notification dot if (sectionId === 'about') { const aboutButton = document.querySelector('.section-button[onclick="showSection(\'about\')"]'); if (aboutButton) { aboutButton.classList.remove('has-notification'); } } // Satellite section // TODO: This stuff should either be in wx.js or SAT_URLS moved here. if (sectionId === 'satellite') { // Load weather image when satellite section is shown const weatherImage = document.getElementById('weather-image'); weatherImage.src = SAT_URLS.latest; } else { // Remove weather img src to force reload when switching back const weatherImage = document.getElementById('weather-image'); if (weatherImage) { weatherImage.src = ''; } } // Update network info if the network section is visible if (sectionId === 'network') { if (!networkInfoUpdated) { updateNetworkInfo(); networkInfoUpdated = true; } updatePingChart(true); // with animation } // Update Wikipedia data if the landmarks section is visible if (sectionId === 'landmarks') { if (lat !== null && long !== null) { fetchLandmarkData(lat, long); } else { console.log('Location not available for Wikipedia data.'); } } // Hide all sections first const sections = document.querySelectorAll('.section'); sections.forEach(section => { section.style.display = 'none'; }); // Make sure external-site is hidden externalSite.style.display = 'none'; // Show the selected section const section = document.getElementById(sectionId); if (section) { section.style.display = 'block'; } // Deactivate all buttons const buttons = document.querySelectorAll('.section-button'); buttons.forEach(button => { button.classList.remove('active'); }); // Activate the clicked button const button = document.querySelector(`.section-button[onclick="showSection('${sectionId}')"]`); if (button) { button.classList.add('active'); } // Update the current section variable currentSection = sectionId; }; // ***** Main code ***** // Update link click event listener document.addEventListener('click', function (e) { if (e.target.tagName === 'A' && !e.target.closest('.section-buttons')) { e.preventDefault(); const inFrame = e.target.hasAttribute('data-frame'); loadExternalUrl(e.target.href, inFrame); } }); // Handle browser back/forward buttons window.addEventListener('popstate', () => { showSection(getInitialSection()); }); // Handle page visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { stopGPSUpdates(); pauseNewsUpdates(); pausePingTest(); } else { startGPSUpdates(); resumeNewsUpdates(); resumePingTest(); } }); // Event listeners and initialization after DOM content is loaded document.addEventListener('DOMContentLoaded', async function () { // Log console.log('DOM fully loaded and parsed...'); // Attempt login from URL parameter or cookie await attemptLogin(); // Check for NOTE file and display if present updateServerNote(); // Initialize radar display initializeRadar(); // Start location services startGPSUpdates(); // Start news updates resumeNewsUpdates(); // Begin network sensing startPingTest(); // Get version from vers.php asyncly updateVersion(); // Add event listeners for login modal document.getElementById('login-cancel').addEventListener('click', closeLoginModal); document.getElementById('login-submit').addEventListener('click', handleLogin); // Handle Enter key in login form document.getElementById('user-id').addEventListener('keyup', function (event) { if (event.key === 'Enter') { handleLogin(); } }); // Show the initial section from URL parameter const urlParams = new URLSearchParams(window.location.search); const initialSection = urlParams.get('section') || 'news'; showSection(initialSection); }); PKW/wwPK-Z cloud.svgSV PKPPPK-$Z common.jsSV// Imports import { settings } from './settings.js'; // Global variables const GEONAMES_USERNAME = 'birgefuller'; let locationTimeZone = browserTimeZone(); let testMode = false; // Set to true if test parameter exists // Exports export { locationTimeZone, testMode, GEONAMES_USERNAME } // Set time zone based on location export async function updateTimeZone(lat, long) { try { const response = await fetch(`https://secure.geonames.org/timezoneJSON?lat=${lat}&lng=${long}&username=${GEONAMES_USERNAME}`); const tzData = await response.json(); if (!tzData || !tzData.timezoneId) { throw new Error('Timezone not returned from server.'); } console.log('Timezone: ', tzData.timezoneId); return tzData.timezoneId; } catch (error) { console.error('Error fetching timezone: ', error); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; console.log('Fallback timezone: ', tz); return tz; } } // Update element with a change-dependent highlight effect export function highlightUpdate(id, content = null) { const element = document.getElementById(id); if (content !== null) { if (element.innerHTML === content) { return; // Exit if content is the same } element.innerHTML = content; } const highlightColor = getComputedStyle(document.documentElement).getPropertyValue('--tesla-blue').trim(); // const originalFontWeight = getComputedStyle(element).fontWeight; element.style.transition = 'color 0.5s, font-weight 0.5s'; element.style.color = highlightColor; // element.style.fontWeight = '800'; setTimeout(() => { element.style.transition = 'color 2s, font-weight 2s'; element.style.color = ''; // Reset to default color // element.style.fontWeight = ''; // Reset to original font weight }, 2000); } // Update src of element only if it needs to change to avoid reloads export function srcUpdate(id, url) { const element = document.getElementById(id); const currentUrl = element.src; console.log('current src:', currentUrl); console.log('new src:', url); if (!(url === currentUrl)) { element.src = url; console.log('Updating src for', id); } } // Helper function to format time according to user settings export function formatTime(date, options = {}) { // Default options const defaultOptions = { hour: 'numeric', minute: '2-digit', timeZone: locationTimeZone }; // Merge provided options with defaults const timeOptions = {...defaultOptions, ...options}; // Check if 24-hour format is enabled in settings if (settings && settings['24-hour-time']) { timeOptions.hour12 = false; } return date.toLocaleTimeString('en-US', timeOptions); } // Return time zone based on browser settings function browserTimeZone() { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; console.log('Browser timezone: ', tz); return tz; } // ***** Initialization ***** // URL parameters const urlParams = new URLSearchParams(window.location.search); const testParam = urlParams.get('test'); testMode = testParam === 'true'; if (testMode) { console.log('##### TEST MODE #####'); } PK,M PK-G.Z favicon.svgSV PK=PK-G.Z git_info.phpSV 'unknown', 'branch' => null, 'tag' => null ]; // Get commit hash and branch name from .git/HEAD $gitHeadFile = __DIR__ . '/.git/HEAD'; if (file_exists($gitHeadFile)) { $headContent = trim(file_get_contents($gitHeadFile)); if (strpos($headContent, 'ref:') === 0) { // HEAD points to a branch $branchName = str_replace('ref: refs/heads/', '', $headContent); $gitInfo['branch'] = $branchName; // Resolve branch to commit hash $branchRefFile = __DIR__ . '/.git/refs/heads/' . $branchName; if (file_exists($branchRefFile)) { $gitInfo['commit'] = substr(trim(file_get_contents($branchRefFile)), 0, 8); } } else { // Detached HEAD state, HEAD contains the commit hash $gitInfo['commit'] = substr($headContent, 0, 8); // Truncate to 8 digits } } // Check for tag name $gitTagsDir = __DIR__ . '/.git/refs/tags/'; if (is_dir($gitTagsDir)) { $tags = scandir($gitTagsDir); foreach ($tags as $tag) { if ($tag === '.' || $tag === '..') { continue; } $tagRefFile = $gitTagsDir . $tag; if (file_exists($tagRefFile) && substr(trim(file_get_contents($tagRefFile)), 0, 8) === $gitInfo['commit']) { $gitInfo['tag'] = $tag; break; } } } return $gitInfo; } PKfQPK-G.Z icons.svgSV PK*mc/PK-jZ index.htmlSV teslas.cloud (beta)

teslas.cloud (beta)

Headlines...

Extended Forecast

Current Conditions

Humidity

--

Wind

--

AQI

--

Sunrise

--

Sunset

--

Moon Phase

--

Links

Nearby Landmarks

Landmark information loading...

IP Address

N/A

Exit Location

N/A

Provider

N/A

Network Latency


Testing

Random

Retro Games (Beta)

These may not all work well on the Tesla browser, so they are here for people to test. Feel free to provide feedback to feedback@teslas.cloud.

Announcements

About

This service was created by Jonathan Birge and Alex Birge as an educational father-son project. It is in no way affiliated with Tesla, Inc. No warranty is made as to its utility for any purpose.

For questions, issues, or feedback: email feedback@teslas.cloud or submit an issue to the GitHub repository.

Apologies to those outside of the US; right now some functionality is limited to CONUS.

Quick Tips

  • Bookmark this page using the star in the browser menu.
  • Make the browser easily available by dragging it to the left-hand side of the dock at the bottom of the screen.
  • Settings are saved by default, but you can logout (under Settings) to disable this.
  • You can also create a user ID that will allow settings to be saved across multiple browsers/cars:
    1. Ensure you're logged out of the default user account in Settings.
    2. Select the 'login' button on the main page and choose an ID with 9 or more characters.
    3. The user ID can be anything you can remember; it is encrypted before being sent to our servers.
  • A blue dot next to a section button indicates updated information within that section.

Acknowledgements

Thanks to whomever made the fantastic "TeslaWaze" site, one of the embedded options in the dashboard. Thank you, as well, to the friendly folks on Tesla Owners Online, who gave considerable helpful feedback and support on early versions.

Version

--

Settings

Allow forwarding news feed items to email
Forward news items instead of opening them in browser

News Feeds

General

Business & Technology

Electric Vehicles

PK#?PK-G.Z location.jsSV/** * Position Simulator Module * Simulates GPS positions for testing purposes */ /** * Class representing a position simulator that generates fake GPS positions for testing */ export class PositionSimulator { /** * Create a position simulator * @param {Object} config - Configuration options * @param {number} [config.centerLat=39.7392] - Center latitude (Denver) * @param {number} [config.centerLong=-104.9903] - Center longitude (Denver) * @param {number} [config.radius=10] - Circle radius in miles * @param {number} [config.minSpeed=75] - Minimum speed in mph * @param {number} [config.maxSpeed=95] - Maximum speed in mph * @param {number} [config.minAlt=50] - Minimum altitude in feet * @param {number} [config.maxAlt=250] - Maximum altitude in feet */ constructor(config = {}) { // Configuration with defaults this.centerLat = config.centerLat ?? 39.7392; // Denver this.centerLong = config.centerLong ?? -104.9903; // Denver this.radius = config.radius ?? 10; // miles this.minSpeed = config.minSpeed ?? 75; // mph this.maxSpeed = config.maxSpeed ?? 95; // mph this.minAlt = config.minAlt ?? 50; // feet this.maxAlt = config.maxAlt ?? 250; // feet // Internal state this._angle = 0; this._speed = this.minSpeed; this._alt = this.minAlt; this._speedIncreasing = true; this._altIncreasing = true; } /** * Reset the simulator to initial state */ reset() { this._angle = 0; this._speed = this.minSpeed; this._alt = this.minAlt; this._speedIncreasing = true; this._altIncreasing = true; return this; } /** * Update the simulator configuration * @param {Object} config - Configuration parameters to update * @returns {PositionSimulator} - This simulator instance for chaining */ updateConfig(config = {}) { if (config.centerLat !== undefined) this.centerLat = config.centerLat; if (config.centerLong !== undefined) this.centerLong = config.centerLong; if (config.radius !== undefined) this.radius = config.radius; if (config.minSpeed !== undefined) this.minSpeed = config.minSpeed; if (config.maxSpeed !== undefined) this.maxSpeed = config.maxSpeed; if (config.minAlt !== undefined) this.minAlt = config.minAlt; if (config.maxAlt !== undefined) this.maxAlt = config.maxAlt; return this; } /** * Get a simulated position and ratchet to next state * @returns {Object} Position object compatible with Geolocation API format */ getPosition() { // Calculate new position based on angle const radiusInDegrees = this.radius / 69; // Rough conversion from miles to degrees const testLat = this.centerLat + radiusInDegrees * Math.cos(this._angle); const testLong = this.centerLong + radiusInDegrees * Math.sin(this._angle); // Update angle for next time (move about 1 degree per second at current speed) const angleIncrement = (this._speed / (2 * Math.PI * this.radius)) * (2 * Math.PI) / (60 * 60); this._angle = (this._angle + angleIncrement) % (2 * Math.PI); // Update speed (oscillate between min and max) if (this._speedIncreasing) { this._speed += 0.1; if (this._speed >= this.maxSpeed) { this._speedIncreasing = false; } } else { this._speed -= 0.1; if (this._speed <= this.minSpeed) { this._speedIncreasing = true; } } // Update altitude (oscillate between min and max) if (this._altIncreasing) { this._alt += 0.5; if (this._alt >= this.maxAlt) { this._altIncreasing = false; } } else { this._alt -= 0.5; if (this._alt <= this.minAlt) { this._altIncreasing = true; } } // Calculate heading based on movement around the circle const heading = (((this._angle * 180 / Math.PI) + 90) % 360); return { coords: { latitude: testLat, longitude: testLong, altitude: this._alt * 0.3048, // Convert feet to meters speed: this._speed * 0.44704, // Convert mph to m/s heading: heading, accuracy: 5, // Simulate a good GPS signal with 5m accuracy }, timestamp: Date.now(), }; } /** * Get the current configuration and state of the simulator * @returns {Object} Configuration object with all settings and current state */ getConfig() { return { centerLat: this.centerLat, centerLong: this.centerLong, radius: this.radius, minSpeed: this.minSpeed, maxSpeed: this.maxSpeed, minAlt: this.minAlt, maxAlt: this.maxAlt, currentSpeed: this._speed, currentAlt: this._alt, currentAngle: this._angle }; } } PKw,σPK-$Znet.jsSV// Import the console.log function from app.js import { hashedUser } from './settings.js'; // Global variables const pingWait = 10*1000; // 10 seconds let pingInterval = null; let pingChart = null; let pingData = []; let userLocation = { latitude: null, longitude: null, altitude: null }; // Get user's current geolocation coordinates function getUserLocation() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { userLocation.latitude = position.coords.latitude; userLocation.longitude = position.coords.longitude; userLocation.altitude = position.coords.altitude || null; // console.log('Got user location: ', // userLocation.latitude.toFixed(4) + ', ' + // userLocation.longitude.toFixed(4) + // (userLocation.altitude ? ', alt: ' + userLocation.altitude.toFixed(1) + 'm' : '')); }, (error) => { console.log('Error getting location: ', error.message); } ); } else { console.log('Geolocation is not supported by this browser'); } } // Fetches and displays network information including IP details and reverse DNS export function updateNetworkInfo() { // Write diagnostic information to the console console.log('Updating network info...'); // Get updated location info getUserLocation(); // Get detailed IP info from ipapi.co fetch('https://ipapi.co/json/') .then(response => response.json()) .then(ipData => { // Get reverse DNS using Google's public DNS API const ip = ipData.ip; // Reverse the IP address and fetch the PTR record const revIp = ip.split('.').reverse().join('.'); return Promise.all([ Promise.resolve(ipData), fetch(`https://dns.google.com/resolve?name=${revIp}.in-addr.arpa&type=PTR`) .then(response => response.json()) ]); }) .then(([ipData, dnsData]) => { // Get the PTR record if it exists const rdnsName = dnsData.Answer ? dnsData.Answer[0].data : ipData.ip; // Update the UI with the fetched data document.getElementById('rdns').innerText = rdnsName; document.getElementById('exitLocation').innerText = `${ipData.city || 'N/A'}, ${ipData.region || 'N/A'}, ${ipData.country_name || 'N/A'}`; document.getElementById('isp').innerText = ipData.org || 'N/A'; }) .catch(error => { console.error('Error fetching IP/DNS information: ', error); console.log('Error fetching IP/DNS information: ', error); // Set N/A values in case of error document.getElementById('rdns').innerText = 'N/A'; document.getElementById('exitLocation').innerText = 'N/A'; document.getElementById('isp').innerText = 'N/A'; }); } // Initializes and starts periodic ping tests to measure network latency export function startPingTest() { // Get initial location getUserLocation(); // Only initialize the chart if it doesn't exist yet if (!pingChart) { pingData = []; initializePingChart(); } // Start pinging every 5 seconds if not already running if (!pingInterval) { pingInterval = setInterval(pingTestServer, pingWait); // Run a ping immediately pingTestServer(); } } // Cleans up the ping chart instance to prevent memory leaks function destroyPingChart() { if (pingChart) { pingChart.destroy(); pingChart = null; console.log('Ping chart destroyed'); } } // Creates and configures the chart visualization for ping data function initializePingChart() { // First, ensure any existing chart is destroyed destroyPingChart(); const chartCanvas = document.getElementById('pingChart'); if (!chartCanvas) return; // Get the 2D context for the chart const ctx = chartCanvas.getContext('2d'); // Logging console.log('Initializing ping chart...'); // Get the Tesla blue color from CSS const teslaBlue = getComputedStyle(document.documentElement).getPropertyValue('--tesla-blue').trim(); // Create gradient const gradient = ctx.createLinearGradient(0, 0, 0, 400); gradient.addColorStop(0, teslaBlue + '50'); // 25% opacity at top gradient.addColorStop(0.5, teslaBlue + '00'); // 0% opacity at bottom gradient.addColorStop(1, teslaBlue + '00'); // 0% opacity at bottom pingChart = new Chart(ctx, { type: 'line', data: { labels: [], datasets: [{ label: 'Ping (ms)', data: pingData, borderColor: teslaBlue, borderWidth: 5, fill: true, backgroundColor: gradient, pointRadius: 0 }] }, options: { responsive: true, animation: true, scales: { x: { type: 'linear', display: true, grid: { color: 'black' }, ticks: { color: 'black', font: { family: 'Inter', size: 14, weight: 600 } }, title: { display: true, text: 'Ping Count', color: 'black', font: { family: 'Inter', size: 16, weight: 600 } } }, y: { display: true, beginAtZero: true, grid: { color: 'black' }, ticks: { color: 'black', font: { family: 'Inter', size: 14, weight: 600 } }, title: { display: true, text: 'Latency (ms)', color: 'black', font: { family: 'Inter', size: 16, weight: 600 } } } }, plugins: { legend: { display: false } } } }); updateChartAxisColors(); // Ensure initial colors are right // Logging console.log('Ping chart initialized'); } // Updates chart colors based on current theme settings export function updateChartAxisColors() { // Console log console.log('Updating chart axis colors...'); // Get computed values from body element instead of document.documentElement const computedStyle = getComputedStyle(document.body); const axisColor = computedStyle.getPropertyValue('--button-text').trim(); const gridColor = computedStyle.getPropertyValue('--separator-color').trim(); // Update chart options if (pingChart) { pingChart.options.scales.x.ticks.color = axisColor; pingChart.options.scales.y.ticks.color = axisColor; pingChart.options.scales.x.grid.color = gridColor; pingChart.options.scales.y.grid.color = gridColor; pingChart.options.scales.y.title.color = axisColor; pingChart.options.scales.x.title.color = axisColor; pingChart.update(); } } // Performs a ping test and records the result async function pingTestServer() { // Get updated location data getUserLocation(); // Prepare form data with user and location information const formData = new FormData(); formData.append('user_id', hashedUser || 'anonymous'); if (userLocation.latitude !== null) { formData.append('latitude', userLocation.latitude); } if (userLocation.longitude !== null) { formData.append('longitude', userLocation.longitude); } if (userLocation.altitude !== null) { formData.append('altitude', userLocation.altitude); } // Send a low-overhead HEAD request to the server const startTime = performance.now(); try { const response = await fetch('ping.php', { method: 'HEAD' }); if (!response.ok) { throw new Error('Network response was not ok'); } await response.text(); const pingTime = performance.now() - startTime; // ms // Discard the first ping // if (!pingTestServer.firstPingDiscarded) { // pingTestServer.firstPingDiscarded = true; // console.log('First ping discarded: ', Math.round(pingTime)); // return; // } updateNetworkStatus(pingTime); pingData.push(pingTime); if (pingData.length > 100) { pingData.shift(); // Keep last n pings } // Only update chart if network section is visible const networkSection = document.getElementById('network'); if (networkSection && networkSection.style.display === 'block' && pingChart) { updatePingChart(true); // Update with animation } } catch (error) { console.log('Ping HEAD failed: ', error); } // Add last ping time to form data as a string formData.append('ping', pingData.at(-1).toFixed(1)); try { const response = await fetch('ping.php', { method: 'POST', body: formData, cache: 'no-store' }); if (!response.ok) { throw new Error('Network response was not ok'); } } catch (error) { console.log('Ping POST failed: ', error); } } // Updates the ping chart with current data, with optional animation export function updatePingChart(animated = false) { if (pingChart) { pingChart.data.labels = Array.from({ length: pingData.length }, (_, i) => i); if (animated) { pingChart.update(); } else { pingChart.update('none'); // Update without animation for better performance } } } // Updates the visual network status indicator based on ping latency function updateNetworkStatus(pingTime) { const networkStatus = document.getElementById('network-status'); if (!networkStatus) return; // Remove all current classes networkStatus.classList.remove('unavailable', 'poor', 'fair', 'good', 'excellent'); // Set class based on ping time if (pingTime === null || pingTime > 1000) { networkStatus.classList.add('unavailable'); networkStatus.setAttribute('title', 'Network Status: Poor (>500ms)'); } else if (pingTime > 500) { networkStatus.classList.add('poor'); networkStatus.setAttribute('title', `Network Status: Poor (${Math.round(pingTime)}ms)`); } else if (pingTime > 250) { networkStatus.classList.add('fair'); networkStatus.setAttribute('title', `Network Status: Fair (${Math.round(pingTime)}ms)`); } else if (pingTime > 50) { networkStatus.classList.add('good'); networkStatus.setAttribute('title', `Network Status: Good (${Math.round(pingTime)}ms)`); } else { networkStatus.classList.add('excellent'); networkStatus.setAttribute('title', `Network Status: Excellent (${Math.round(pingTime)}ms)`); } } // Stops the automatic ping testing window.pausePingTest = function() { if (pingInterval) { clearInterval(pingInterval); pingInterval = null; console.log('Network ping testing paused'); } } // Resumes automatic ping testing window.resumePingTest = function() { if (!pingInterval) { // Ping the server immediately pingTestServer(); // Resume pinging every 10 seconds pingInterval = setInterval(pingTestServer, pingWait); console.log('Network ping testing resumed'); } } // Initialize the firstPingDiscarded flag // pingTestServer.firstPingDiscarded = false; PK$iC1C1PK-$Znews.jsSV// Imports import { settings } from './settings.js'; // Constants const NEWS_REFRESH_INTERVAL = 5; // minutes // Variables let newsItems = null; // Current array of news items let newsUpdateInterval = null; let seenNewsIds = new Set(); // Track news IDs we've already seen let newsTimeUpdateInterval = null; // Interval for updating "time ago" displays // Updates the news headlines, optionally clearing existing ones export async function updateNews(clear = false) { try { // Collect included RSS feeds from user settings const includedFeeds = []; if (settings) { // Collect all RSS feed settings that are set to true for (const key in settings) { if (key.startsWith('rss-') && settings[key] === true) { // Extract feed ID after the "rss-" prefix const feedId = key.substring(4); includedFeeds.push(feedId); } } } // Allows for adding options to the URL for future use const baseUrl = 'rss.php?n=128'; // Get the news container element const newsContainer = document.getElementById('newsHeadlines'); if (!newsContainer) return; // Clear the news container as needed if (clear) { console.log('Clearing news headlines...'); newsContainer.innerHTML = ''; seenNewsIds.clear(); // Clear seen news IDs newsItems = null; // Clear news items } // Show loading spinner if no items are displayed yet or only showing a message const isEmpty = !newsContainer.innerHTML || newsContainer.innerHTML.includes('') || newsContainer.innerHTML.trim() === ''; if (isEmpty) { newsContainer.innerHTML = '
'; } console.log('Fetching news headlines...'); if (includedFeeds.length > 0) { console.log('Including RSS feeds:', includedFeeds); } else { console.log('No RSS feeds selected, showing all available feeds'); } // Send the request with included feeds in the body const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ includedFeeds }) }); const loadedItems = await response.json(); // Remove the spinner when data arrives const spinnerContainer = newsContainer.querySelector('.spinner-container'); if (spinnerContainer) { spinnerContainer.remove(); } // Create list of new items let hasNewItems = false; const newItems = []; if (loadedItems.length > 0) { // Generate unique IDs for each news item loadedItems.forEach(item => { // Create a unique ID based on title and source item.id = genItemID(item); // Check if this is a new item if (!seenNewsIds.has(item.id)) { item.isUnread = true; // Mark as unread hasNewItems = true; newItems.push(item); seenNewsIds.add(item.id); } }); // If we have new items, update notification dot if (hasNewItems) { // Add notification dot if news section is not currently displayed const newsSection = document.getElementById('news'); if (newsSection && newsSection.style.display !== 'block') { const newsButton = document.querySelector('.section-button[onclick="showSection(\'news\')"]'); if (newsButton) { newsButton.classList.add('has-notification'); } } } } // Merge new items with existing ones if (newsItems) { newsItems = [...newItems, ...newsItems]; } else { newsItems = newItems; } // Sort items by date newsItems.sort((a, b) => b.date - a.date); // Update the news container with the new items if (newsItems.length > 0) { newsContainer.innerHTML = newsItems.map(generateHTMLforItem).join(''); } else { newsContainer.innerHTML = '

No headlines available

'; } } catch (error) { console.error('Error fetching news:', error); console.log('Error fetching news:', error); const newsContainer = document.getElementById('newsHeadlines'); // Make sure to remove the spinner even in case of an error if (newsContainer) { const spinnerContainer = newsContainer.querySelector('.spinner-container'); if (spinnerContainer) { spinnerContainer.remove(); } newsContainer.innerHTML = '

Error loading headlines

'; } } // Update the visibility of share buttons setShareButtonsVisibility(); } // Set visibility of the share buttons based on settings export function setShareButtonsVisibility() { const shareButtons = document.querySelectorAll('.share-icon'); shareButtons.forEach(button => { if (settings["news-forwarding"]) { button.style.display = 'block'; } else { button.style.display = 'none'; } }); } // Updates all news timestamp displays on the page export function updateNewsTimeDisplays() { const timeElements = document.querySelectorAll('.news-time[data-timestamp]'); timeElements.forEach(element => { const timestamp = parseInt(element.getAttribute('data-timestamp')); if (!isNaN(timestamp)) { element.textContent = generateTimeAgoText(timestamp); } }); } // Start the interval that updates time ago displays export function startNewsTimeUpdates() { console.log('Starting news time updates'); // Clear any existing interval first if (newsTimeUpdateInterval) { clearInterval(newsTimeUpdateInterval); } // Update immediately updateNewsTimeDisplays(); // Then set up interval to update every second newsTimeUpdateInterval = setInterval(updateNewsTimeDisplays, 5000); } // Stop the interval that updates time ago displays export function stopNewsTimeUpdates() { console.log('Stopping news time updates'); if (newsTimeUpdateInterval) { clearInterval(newsTimeUpdateInterval); newsTimeUpdateInterval = null; } } // Mark all current news items as read export function markAllNewsAsRead() { console.log('Marking all news as read'); if (newsItems) { newsItems.forEach(item => { item.isUnread = false; }); } } // Utility function to generate "time ago" text from a timestamp function generateTimeAgoText(timestamp) { const now = new Date(); const itemDate = new Date(timestamp * 1000); const timeDifference = Math.floor((now - itemDate) / 1000); // Difference in seconds if (timeDifference < 60) { return `${timeDifference} seconds ago`; } else if (timeDifference < 7200) { const minutes = Math.floor(timeDifference / 60); return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; } else if (timeDifference < 86400) { const hours = Math.floor(timeDifference / 3600); return `${hours} hour${hours > 1 ? 's' : ''} ago`; } else { const days = Math.floor(timeDifference / 86400); const remainingSeconds = timeDifference % 86400; const hours = Math.floor(remainingSeconds / 3600); return `${days} day${days > 1 ? 's' : ''} and ${hours} hour${hours > 1 ? 's' : ''} ago`; } } // Generate unique IDs for news items function genItemID(item) { // Create a unique ID based on title and source return `${item.source}-${item.title.substring(0, 40)}`; } // Takes news item and generates HTML for it function generateHTMLforItem(item) { // If the item is unread, add a class to highlight it let classList = null; if (item.isUnread) { classList = 'news-item news-new'; } else { classList = 'news-item'; } // Extract domain for favicon either from the item.icon or from item.link if available let faviconUrl = ''; if (item.icon && typeof item.icon === 'string' && item.icon.trim() !== '') { // Use the domain from the icon key faviconUrl = `https://www.google.com/s2/favicons?domain=${item.icon}&sz=32`; } else { try { const url = new URL(item.link); faviconUrl = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; } catch (e) { console.error('Error parsing URL for favicon:', e); } } return `
${item.source.toUpperCase()} ${generateTimeAgoText(item.date)}
${item.title}
`; } window.clickNews = async function (title, link, source) { if (settings["news-forward-only"]) { await shareNews(title, link, source); } else { loadExternalUrl(link); } } // Forwards the news item link to the share function window.shareNews = async function (title, link, source) { console.log('Sharing news item:', title, link); // E-mail address to share with if (settings["forwarding-email"] === '') { return; } const to = settings["forwarding-email"]; // Compose HTML payload const html = `

${source}

${title}

Sent from teslas.cloud

`; // Create the subject line const subject = `[teslas.cloud] ${title}`; // Communicate with the forwarding server try { const response = await fetch('share.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ to, html, subject }) }); if (response.ok) { const alertBox = document.createElement('div'); alertBox.textContent = 'Article shared successfully'; alertBox.style.position = 'fixed'; alertBox.style.top = '20px'; alertBox.style.left = '50%'; alertBox.style.transform = 'translateX(-50%)'; alertBox.style.backgroundColor = "rgb(15, 181, 21) "; alertBox.style.color = 'white'; alertBox.style.padding = '15px'; alertBox.style.borderRadius = '5px'; alertBox.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.3)'; alertBox.style.zIndex = '9999'; document.body.appendChild(alertBox); setTimeout(() => { document.body.removeChild(alertBox); }, 5000); } else { const errorText = await response.text(); alert('Failed to share article: ' + errorText); } } catch (err) { alert('Error sharing article: ' + err); } } // Pauses the automatic news updates window.pauseNewsUpdates = function () { if (newsUpdateInterval) { clearInterval(newsUpdateInterval); newsUpdateInterval = null; console.log('News updates paused'); } } // Resumes the automatic news updates if paused window.resumeNewsUpdates = function () { if (!newsUpdateInterval) { updateNews(); // Call immediately newsUpdateInterval = setInterval(updateNews, 60000 * NEWS_REFRESH_INTERVAL); console.log('News updates resumed'); } } PK4A00PK-G.Zopenwx_proxy.phpSV $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Check if API key is set if (!isset($_ENV['OPENWX_KEY'])) { http_response_code(500); echo json_encode(['error' => 'API key not found in .env file']); exit; } // Get query parameters $queryParams = $_GET; // Add API key to query parameters $queryParams['appid'] = $_ENV['OPENWX_KEY']; // Get the API endpoint path from the URL path info $pathInfo = isset($_SERVER['PATH_INFO']) ? $_SERVER['PATH_INFO'] : ''; if (empty($pathInfo)) { http_response_code(400); echo json_encode(['error' => 'No API endpoint specified in path']); exit; } // Remove leading slash if present $pathInfo = ltrim($pathInfo, '/'); // Build the proxied URL $baseUrl = 'https://api.openweathermap.org/'; $proxiedUrl = $baseUrl . $pathInfo . '?' . http_build_query($queryParams); // Initialize cURL $ch = curl_init($proxiedUrl); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_HEADER, false); // Execute cURL request $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); // Set HTTP response code and output the response http_response_code($httpCode); header('Content-Type: application/json'); echo $response; PKNPPPK-G.Zping.phpSV $value) { $_ENV[$key] = $value; } } else { logMessage("Failed to parse .env file: " . json_last_error_msg(), "ERROR"); http_response_code(500); header('Content-Type: text/plain'); echo "Error parsing .env file."; exit; } } else { logMessage(".env file not found at $envFilePath", "WARNING"); http_response_code(500); header('Content-Type: text/plain'); echo "Configuration file not found."; exit; } // SQL database configuration $dbName = $_ENV['SQL_DB_NAME'] ?? 'teslacloud'; $dbHost = $_ENV['SQL_HOST'] ?? null; $dbUser = $_ENV['SQL_USER'] ?? null; $dbPass = $_ENV['SQL_PASS'] ?? null; $dbPort = $_ENV['SQL_PORT'] ?? '3306'; // Get client IP address $clientIP = $_SERVER['REMOTE_ADDR']; if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP)) { $clientIP = $_SERVER['HTTP_X_FORWARDED_FOR']; } // Establish database connection if ($dbHost && $dbName && $dbUser) { try { $dsn = "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $dbConnection = new PDO($dsn, $dbUser, $dbPass, $options); // Check if the ping_data table exists, create it if not $tableCheck = $dbConnection->query("SHOW TABLES LIKE 'ping_data'"); if ($tableCheck->rowCount() == 0) { $sql = "CREATE TABLE ping_data ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, latitude DOUBLE NULL, longitude DOUBLE NULL, altitude DOUBLE NULL, ip_address VARCHAR(45) NULL, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )"; $dbConnection->exec($sql); logMessage("Created ping_data table"); } // Get data from POST request $userId = $_POST['user_id'] ?? 'anonymous'; $latitude = isset($_POST['latitude']) ? (double)$_POST['latitude'] : null; $longitude = isset($_POST['longitude']) ? (double)$_POST['longitude'] : null; $altitude = isset($_POST['altitude']) ? (double)$_POST['altitude'] : null; $pingTime = isset($_POST['ping']) ? (double)$_POST['ping'] : null; // Log the ping data to database $stmt = $dbConnection->prepare("INSERT INTO ping_data (user_id, latitude, longitude, altitude, ip_address, ping_time) VALUES (?, ?, ?, ?, ?, ?)"); $stmt->execute([$userId, $latitude, $longitude, $altitude, $clientIP, $pingTime]); // Respond with 200 OK header('Content-Type: text/plain'); echo "Ping logged successfully."; logMessage("Logged ping from user: " . $userId . ", IP: " . $clientIP); } catch (PDOException $e) { logMessage("Database error: " . $e->getMessage(), "ERROR"); http_response_code(500); header('Content-Type: text/plain'); echo "Database error: " . $e->getMessage(); exit; } } else { logMessage("Missing database configuration", "WARNING"); http_response_code(500); header('Content-Type: text/plain'); echo "Database configuration is missing."; exit; } // Simple logging function function logMessage($message, $level = 'INFO') { global $logFile; $timestamp = date('Y-m-d H:i:s'); $formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL; file_put_contents($logFile, $formattedMessage, FILE_APPEND); } PKV-PK-%Zrss.phpSV ['url' => 'https://feeds.content.dowjones.io/public/rss/RSSWorldNews', 'cache' => 5], 'nyt' => ['url' => 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', 'cache' => 5], 'wapo' => ['url' => 'https://www.washingtonpost.com/arcio/rss/category/politics/', 'cache' => 15], 'latimes' => ['url' => 'https://www.latimes.com/rss2.0.xml', 'cache' => 15], 'bos' => ['url' => 'https://www.boston.com/tag/local-news/feed', 'cache' => 15], 'den' => ['url' => 'https://www.denverpost.com/feed/', 'cache' => 15], 'chi' => ['url' => 'https://www.chicagotribune.com/news/feed/', 'cache' => 15], 'bbc' => ['url' => 'http://feeds.bbci.co.uk/news/world/rss.xml', 'cache' => 15], 'lemonde' => ['url' => 'https://www.lemonde.fr/rss/une.xml', 'cache' => 60], 'bloomberg' => ['url' => 'https://feeds.bloomberg.com/news.rss', 'cache' => 15], 'economist' => ['url' => 'https://www.economist.com/latest/rss.xml', 'cache' => 60], 'cnn' => ['url' => 'https://openrss.org/www.cnn.com', 'cache' => 15, 'icon' => 'https://www.cnn.com/'], 'ap' => ['url' => 'https://news.google.com/rss/search?q=when:24h+allinurl:apnews.com&hl=en-US&gl=US&ceid=US:en', 'cache' => 30, 'icon' => 'https://apnews.com/'], 'notateslaapp' => ['url' => 'https://www.notateslaapp.com/rss', 'cache' => 30], 'teslarati' => ['url' => 'https://www.teslarati.com/feed/', 'cache' => 30], 'insideevs' => ['url' => 'https://insideevs.com/rss/articles/all/', 'cache' => 30], 'thedrive' => ['url' => 'https://www.thedrive.com/feed', 'cache' => 30], 'caranddriver' => ['url' => 'https://www.caranddriver.com/rss/all.xml/', 'cache' => 30], 'techcrunch' => ['url' => 'https://techcrunch.com/feed/', 'cache' => 30], 'arstechnica' => ['url' => 'https://feeds.arstechnica.com/arstechnica/index', 'cache' => 30], 'engadget' => ['url' => 'https://www.engadget.com/rss.xml', 'cache' => 30], 'gizmodo' => ['url' => 'https://gizmodo.com/rss', 'cache' => 30], 'theverge' => ['url' => 'https://www.theverge.com/rss/index.xml', 'cache' => 30], 'wired' => ['url' => 'https://www.wired.com/feed/rss', 'cache' => 30], 'spacenews' => ['url' => 'https://spacenews.com/feed/', 'cache' => 30], 'defensenews' => ['url' => 'https://www.defensenews.com/arc/outboundfeeds/rss/?outputType=xml', 'cache' => 30], 'aviationweek' => ['url' => 'https://aviationweek.com/awn/rss-feed-by-content-source', 'cache' => 30] ]; // Set up error logging ini_set('log_errors', 1); ini_set('error_log', '/tmp/rss-php-errors.log'); // Custom error handler to capture all types of errors set_error_handler(function($errno, $errstr, $errfile, $errline) { $message = date('[Y-m-d H:i:s] ') . "Error ($errno): $errstr in $errfile on line $errline\n"; error_log($message); return false; // Let PHP handle the error as well }); // Register shutdown function to catch fatal errors register_shutdown_function(function() { $error = error_get_last(); if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) { $message = date('[Y-m-d H:i:s] ') . "FATAL Error: {$error['message']} in {$error['file']} on line {$error['line']}\n"; error_log($message); } }); // Set the content type and add headers to prevent caching header('Cache-Control: no-cache, no-store, must-revalidate'); header('Expires: 0'); header('Content-Type: application/json'); // Check if we're receiving a POST request with included feeds $includedFeeds = []; if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Get the request body $requestBody = file_get_contents('php://input'); $requestData = json_decode($requestBody, true); // Check if includedFeeds is set in the request if (isset($requestData['includedFeeds']) && is_array($requestData['includedFeeds'])) { $includedFeeds = $requestData['includedFeeds']; logMessage("Received included feeds: " . implode(', ', $includedFeeds)); } } // Load timestamp data if it exists $feedTimestamps = []; if (file_exists($cacheTimestampFile)) { $feedTimestamps = json_decode(file_get_contents($cacheTimestampFile), true); if (!is_array($feedTimestamps)) { $feedTimestamps = []; } } // Determine which feeds to process $requestedFeeds = empty($includedFeeds) ? array_keys($feeds) : $includedFeeds; // Collect all items $allItems = []; $currentTime = time(); $updatedTimestamps = false; foreach ($requestedFeeds as $source) { if (!isset($feeds[$source])) continue; $feedData = $feeds[$source]; $cacheFile = "{$cacheDir}/rss_cache_{$source}_{$version}.json"; $cacheDurationSeconds = $feedData['cache'] * 60; $lastUpdated = isset($feedTimestamps[$source]) ? $feedTimestamps[$source] : 0; $useCache = false; if (!$forceReload && file_exists($cacheFile) && ($currentTime - $lastUpdated) <= $cacheDurationSeconds) { // Use cache $cachedItems = json_decode(file_get_contents($cacheFile), true); if (is_array($cachedItems)) { $allItems = array_merge($allItems, $cachedItems); logMessage("Loaded {$source} from cache."); $useCache = true; } } if (!$useCache) { // Fetch and cache $xml = $useSerialFetch ? fetchRSS($feedData['url']) : fetchRSS($feedData['url']); // Only one at a time now if ($xml !== false) { $items = parseRSS($xml, $source); file_put_contents($cacheFile, json_encode($items)); $feedTimestamps[$source] = $currentTime; $updatedTimestamps = true; $allItems = array_merge($allItems, $items); logMessage("Fetched {$source} from internet and updated cache."); } else { logMessage("Failed to fetch {$source} from internet."); } } } // Update timestamps file if needed if ($updatedTimestamps) { file_put_contents($cacheTimestampFile, json_encode($feedTimestamps)); } // Sort by date, newest first usort($allItems, function($a, $b) { return $b['date'] - $a['date']; }); // Log the total number of stories $totalStories = count($allItems); logMessage("Total stories fetched: $totalStories"); // Apply inclusion filters to data $outputItems = applyInclusionFilters($allItems, $requestedFeeds); // Limit number of stories if needed $outputItems = array_slice($outputItems, 0, $numStories); // Return filtered cached content echo json_encode($outputItems); // ***** Utility functions ***** function fetchRSS($url) { $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; RSS Reader/1.0)'); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); $response = curl_exec($ch); if (curl_errno($ch)) { error_log("RSS Feed Error: " . curl_error($ch) . " - URL: " . $url); curl_close($ch); return false; } curl_close($ch); return $response; } function parseRSS($xml, $source) { global $maxSingleSource, $feeds; try { $feed = simplexml_load_string($xml); if (!$feed) { error_log("RSS Parse Error: Failed to parse XML feed from source: {$source}"); return []; } } catch (Exception $e) { error_log("RSS Parse Exception for source {$source}: " . $e->getMessage()); return []; } $items = []; // Handle different RSS feed structures $feedItems = null; if (isset($feed->channel) && isset($feed->channel->item)) { $feedItems = $feed->channel->item; // Standard RSS format } elseif (isset($feed->entry)) { $feedItems = $feed->entry; // Atom format } elseif (isset($feed->item)) { $feedItems = $feed->item; // Some non-standard RSS variants } if (!$feedItems) return []; // Get icon if present for this source $icon = isset($feeds[$source]['icon']) ? $feeds[$source]['icon'] : null; foreach ($feedItems as $item) { // Try to find the publication date in various formats $pubDate = null; $dateString = null; // Check for different date fields if (isset($item->pubDate)) { $dateString = (string)$item->pubDate; } elseif (isset($item->published)) { $dateString = (string)$item->published; } elseif (isset($item->updated)) { $dateString = (string)$item->updated; } elseif (isset($item->children('dc', true)->date)) { $dateString = (string)$item->children('dc', true)->date; } if ($dateString) { // Try to parse the date $pubDate = strtotime($dateString); // If parsing failed, try to reformat common date patterns if ($pubDate === false) { // Try ISO 8601 format (remove milliseconds if present) if (preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $dateString)) { $cleaned = preg_replace('/\.\d+/', '', $dateString); $pubDate = strtotime($cleaned); } // Try common RFC formats with missing timezone if ($pubDate === false && preg_match('/^\w+, \d+ \w+ \d+$/', $dateString)) { $pubDate = strtotime($dateString . " 00:00:00 +0000"); } // Last resort: use current time if ($pubDate === false) { error_log("Failed to parse date: {$dateString} from source: {$source}"); $pubDate = time(); } } } else { // If no date is found, use current time (FIX: bad idea) $pubDate = time(); } // Find the link (which could be in different formats) $link = ""; if (isset($item->link)) { if (is_object($item->link) && isset($item->link['href'])) { $link = (string)$item->link['href']; // Atom format } else { $link = (string)$item->link; // RSS format } } // Find the title $title = isset($item->title) ? (string)$item->title : "No Title"; $newsItem = [ 'title' => $title, 'link' => $link, 'date' => $pubDate, 'source' => $source ]; if ($icon) { $newsItem['icon'] = $icon; } $items[] = $newsItem; // Limit number from single source if (count($items) > $maxSingleSource) { logMessage("Limiting number of stories from source: {$source}"); break; } } logMessage("Fetched " . count($items) . " stories from source: {$source}"); return $items; } // Function to apply exclusion filters to items function applyExclusionFilters($items, $excludedFeeds) { if (empty($excludedFeeds)) { return $items; } logMessage("Filtering out excluded feeds: " . implode(', ', $excludedFeeds)); $filteredItems = array_filter($items, function($item) use ($excludedFeeds) { return !in_array($item['source'], $excludedFeeds); }); // Re-index array after filtering $filteredItems = array_values($filteredItems); logMessage("After filtering: " . count($filteredItems) . " items remain"); return $filteredItems; } // Function to apply inclusion filters to items function applyInclusionFilters($items, $includedFeeds) { if (empty($includedFeeds)) { // If no feeds are specified, include all feeds return $items; } logMessage("Filtering to only include feeds: " . implode(', ', $includedFeeds)); $filteredItems = array_filter($items, function($item) use ($includedFeeds) { return in_array($item['source'], $includedFeeds); }); // Re-index array after filtering $filteredItems = array_values($filteredItems); logMessage("After filtering: " . count($filteredItems) . " items remain"); return $filteredItems; } // Function to write timestamped log messages to the end of the log file function logMessage($message) { global $logFile; file_put_contents($logFile, date('[Y-m-d H:i:s] ') . $message . "\n", FILE_APPEND); } PK馬9/4/4PK-Z settings.jsSV// Imports import { updateNews, setShareButtonsVisibility } from './news.js'; import { updateChartAxisColors } from './net.js'; import { autoDarkMode, updatePremiumWeatherDisplay } from './wx.js'; // Global variables let isLoggedIn = false; let currentUser = null; // Will be NULL if not logged in OR if using auto-generated ID let hashedUser = null; // The hashed version of the user ID let rssIsDirty = false; // Flag to indicate if RSS settings have changed let rssDrop = false; // Flag to indicate if an RSS feed has been dropped let unitIsDirty = false; // Flag to indicate if unit/time settings have changed let settings = {}; // Initialize settings object // Export settings object so it's accessible to other modules export { settings, currentUser, isLoggedIn, hashedUser }; // Default settings that will be used when no user is logged in const defaultSettings = { // General settings "dark-mode": false, "auto-dark-mode": true, "24-hour-time": false, "imperial-units": true, "map-choice": 'waze', "show-wind-radar": true, // News forwarding "news-forwarding": false, "news-forward-only": false, "forwarding-email": "", // News source settings "rss-wsj": true, "rss-nyt": true, "rss-wapo": true, "rss-latimes": false, "rss-bos": false, "rss-den": false, "rss-chi": false, "rss-bloomberg": false, "rss-ap": true, "rss-bbc": false, "rss-economist": false, "rss-lemonde": false, "rss-cnn": false, "rss-notateslaapp": true, "rss-teslarati": true, "rss-insideevs": true, "rss-thedrive": false, "rss-techcrunch": true, "rss-caranddriver": true, "rss-theverge": false, "rss-arstechnica": true, "rss-engadget": false, "rss-gizmodo": false, "rss-wired": false, "rss-spacenews": false, "rss-defensenews": false, "rss-aviationweek": false, }; // Settings section is being left export function leaveSettings() { if (rssIsDirty) { console.log('RSS settings are dirty, updating news feed.') // If RSS is dirty, update the news feed updateNews(rssDrop); rssIsDirty = false; // Reset the dirty flag rssDrop = false; // Reset the drop flag } if (unitIsDirty) { console.log('Unit/time settings are dirty, updating weather display.') updatePremiumWeatherDisplay(); unitIsDirty = false; // Reset the dirty flag } } // Turn on dark mode export function turnOnDarkMode() { console.log('turnOnDarkMode() called'); document.body.classList.add('dark-mode'); document.getElementById('darkModeToggle').checked = true; toggleSetting('dark-mode', true); updateDarkModeDependants(); } // Turn off dark mode export function turnOffDarkMode() { console.log('turnOffDarkMode() called'); document.body.classList.remove('dark-mode'); document.getElementById('darkModeToggle').checked = false; toggleSetting('dark-mode', false); updateDarkModeDependants(); } // Function to attempt login export async function attemptLogin() { const urlParams = new URLSearchParams(window.location.search); let userId = urlParams.get('user'); // deprecated, but keep for now // Check for user-set ID in cookies if not found in URL if (!userId) { userId = getCookie('userid'); } // Get final hashedUserID either from named user or auto-generated user if (userId) { // We have a named user if (await validateUserId(userId)) { console.log('Logged in named user ID: ', userId); await fetchSettings(); } } else { // Fall back to an auto-generated one const autoGeneratedId = getCookie('auto-userid'); if (autoGeneratedId && await validateAutoUserId(autoGeneratedId)) { console.log('Logged in auto-generated ID: ', autoGeneratedId); await fetchSettings(); } else { console.log('No user IDs found, creating new auto-generated user...'); initializeSettings(); const newAutoUser = await autoCreateUser(); // Create a new user and log them in if (await validateAutoUserId(newAutoUser)) { for (const [key, value] of Object.entries(settings)) { await toggleSetting(key, value); } } } } // Log final state of currentUser, hashedUser, and isLoggedIn console.log('currentUser:', currentUser); console.log('hashedUser:', hashedUser); console.log('isLoggedIn:', isLoggedIn); // Initialize map frame option updateMapFrame(); } // Function to toggle a setting (updates both local cache and server) export async function toggleSetting(key, value) { // Handle local settings settings[key] = value; // Update toggle state visually updateToggleVisualState(key, value); console.log(`Setting "${key}" updated to ${value} (local)`); // Update server if logged in if (isLoggedIn && hashedUser) { try { // Update the local settings cache with boolean value settings[key] = value; // Update the server with the boolean value using the RESTful API const response = await fetch(`settings.php/${encodeURIComponent(hashedUser)}/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: value }) }); if (response.ok) { console.log(`Setting "${key}" updated to ${value} (server)`); } else { console.log(`Failed to update setting "${key}" on server`); } } catch (error) { console.log('Error toggling setting:', error); } } // If the setting is RSS-related, set the dirty flag if (key.startsWith('rss-')) { const isDrop = !value; // If unchecked, it's a drop rssIsDirty = true; rssDrop = rssDrop || isDrop; // Set the drop flag if this is a drop console.log(`RSS setting "${key}" changed to ${value} (dirty: ${rssIsDirty}, drop: ${rssDrop})`); } // If the setting is unit/time-related, set the dirty flag if (key === 'imperial-units' || key === '24-hour-time') { unitIsDirty = true; console.log(`Unit/time setting "${key}" changed to ${value} (dirty: ${unitIsDirty})`); } // If the setting is dark mode related, update the dark mode if (key === 'auto-dark-mode') { if (value) { autoDarkMode(); } } // Handle map choice setting if (key === 'map-choice') { updateMapFrame(); } // If the setting is news forwarding, update the share buttons if (key === 'news-forwarding') { setShareButtonsVisibility(); } // Show/hide radar if setting changes if (key === 'show-wind-radar') { updateRadarVisibility(); } } // Function to initialize with defaults function initializeSettings() { settings = { ...defaultSettings }; initializeToggleStates(); updateRadarVisibility(); console.log('Settings initialized: ', settings); } // Update things that depend on dark mode function updateDarkModeDependants() { updateChartAxisColors(); } // Function to show/hide radar display based on setting function updateRadarVisibility() { const radar = document.getElementById('radar-container'); if (radar) { radar.style.display = (settings["show-wind-radar"] === false) ? 'none' : ''; } } // Function to hash a user ID using SHA-256 async function hashUserId(userId) { // Use the SubtleCrypto API to create a SHA-256 hash const encoder = new TextEncoder(); const data = encoder.encode(userId); const hashBuffer = await crypto.subtle.digest('SHA-256', data); // Convert the hash buffer to a hexadecimal string const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // Return only the first 16 characters (64 bits) of the hash return hashHex.substring(0, 16); } // Internal helper function async function validateHashedUserId(hashedId) { try { // Use HEAD request to check if the user exists const response = await fetch(`settings.php/${encodeURIComponent(hashedId)}`, { method: 'HEAD' }); if (response.status === 404) { // User doesn't exist, create default settings const created = await createNewUser(userId, hashedId); if (!created) { return false; } } else if (!response.ok) { return false; } // User exists, set environment variables isLoggedIn = true; hashedUser = hashedId; currentUser = null; console.log('User validated on server: ', hashedId); return true; } catch (error) { isLoggedIn = false; hashedUser = null; currentUser = null; console.error('Error validating user: ', error); return false; } } async function validateAutoUserId(autoUserId) { if (await validateHashedUserId(autoUserId)) { setCookie('auto-userid', autoUserId); updateLoginState(); return true; } else { return false; } } // Function to validate user ID, creating a new user if it doesn't exist async function validateUserId(userId) { // Check for minimum length (9 characters) if (userId.length < 9) { return false; } // Check for standard characters (letters, numbers, underscore, hyphen) const validFormat = /^[a-zA-Z0-9_-]+$/; if (!validFormat.test(userId)) { return false; } // Hash the user ID before sending to the server const hashedId = await hashUserId(userId); if (await validateHashedUserId(hashedId)) { currentUser = userId; setCookie('userid', userId); updateLoginState(); return true; } else { currentUser = null; return false; } } // Function to create a new named user, always called with initialized settings async function createNewUser(userId, hashedId = null) { try { // If hashedId wasn't provided, generate it if (!hashedId) { hashedId = await hashUserId(userId); } const response = await fetch(`settings.php/${encodeURIComponent(hashedId)}`, { method: 'POST' }); if (response.ok) { console.log('Created new user with default settings:', userId); for (const [key, value] of Object.entries(settings)) { await toggleSetting(key, value); } return true; } else { console.log('Failed to create new user:', userId); return false; } } catch (error) { console.error('Error creating new user:', error); return false; } } // Function to generate an auto-generated user from the server and return the hash async function autoCreateUser() { try { const response = await fetch('settings.php', { method: 'POST' }); if (response.ok) { let data = await response.json(); console.log('Auto-generated user ID:', data.userId); return data.userId; } else { console.log('Failed to fetch random user ID from server'); return null; } } catch (error) { console.error('Error fetching random user ID:', error); return null; } } // Update login/logout button visibility based on state function updateLoginState() { const loginButton = document.getElementById('login-button'); const logoutButton = document.getElementById('logout-button'); if (isLoggedIn) { loginButton.classList.add('hidden'); logoutButton.classList.remove('hidden'); if (currentUser) { logoutButton.textContent = `Logout ${currentUser}`; } else { logoutButton.textContent = 'Logout default user'; } } else { loginButton.classList.remove('hidden'); logoutButton.classList.add('hidden'); logoutButton.textContent = 'Logout'; } } // Pull all settings for current valided user from REST server // TODO: fetchSettings should return a boolean indicating success or failure async function fetchSettings() { if (!hashedUser) { console.log('No hashed user ID available, cannot fetch settings.'); return; } try { // Fetch settings using RESTful API console.log('Fetching settings for user: ', hashedUser); const response = await fetch(`settings.php/${encodeURIComponent(hashedUser)}`, { method: 'GET' }); if (response.ok) { // Load settings settings = await response.json(); console.log('Settings loaded: ', settings); // Activate the settings section button document.getElementById('settings-section').classList.remove('hidden'); // Initialize toggle states based on settings initializeToggleStates(); updateRadarVisibility(); // Handle dark mode if (settings['dark-mode']) { turnOnDarkMode(); } else { turnOffDarkMode(); } } else { console.error('Error fetching settings: ', response.statusText); } } catch (error) { console.error('Error fetching settings: ', error); } } // Update visual state of a toggle or text input function updateToggleVisualState(key, value) { const settingItems = document.querySelectorAll(`.settings-toggle-item[data-setting="${key}"]`); // Special compatibility cases if (key === 'imperial-units') { let unitsValue; if (value === true) { value = 'english'; } else { value = 'metric'; } } console.log(`Updating visual state for "${key}" to ${value}`); if (settingItems && settingItems.length > 0) { settingItems.forEach(item => { // Handle checkbox toggle const toggle = item.querySelector('input[type="checkbox"]'); if (toggle) { toggle.checked = value === true; } // Handle option-based toggles if (item.classList.contains('option-switch-container')) { const buttons = item.querySelectorAll('.option-button'); buttons.forEach(btn => { btn.classList.toggle('active', btn.dataset.value === value); }); } // Handle text input if (item.classList.contains('settings-text-item')) { const textInput = item.querySelector('input[type="text"]'); if (textInput) { textInput.value = value || ''; } } }); } // Disable/enable forwarding-email input based on news-forwarding if (key === 'news-forwarding') { setControlEnable('forwarding-email', value); setControlEnable('news-forward-only', value); } } function setControlEnable(key, enabled = true) { const settingItems = document.querySelectorAll(`div[data-setting="${key}"]`); if (settingItems && settingItems.length > 0) { settingItems.forEach(item => { // Make div partly transparent item.style.opacity = enabled ? '1' : '0.35'; // Handle checkbox toggle const toggle = item.querySelector('input[type="checkbox"]'); if (toggle) { toggle.disabled = !enabled; } // Handle option-based toggles if (item.classList.contains('option-switch-container')) { const buttons = item.querySelectorAll('.option-button'); buttons.forEach(btn => { btn.disabled = !enabled; }); } // Handle text input if (item.classList.contains('settings-text-item')) { const textInput = item.querySelector('input[type="text"]'); if (textInput) { textInput.disabled = !enabled; } } }); } } // Initialize all toggle and text states based on 'settings' dictionary function initializeToggleStates() { // Iterate through all keys in the settings object for (const key in settings) { if (settings.hasOwnProperty(key)) { const value = settings[key]; updateToggleVisualState(key, value); } } updateRadarVisibility(); } // Helper function to get current domain for cookie namespacing function getCurrentDomain() { // Get hostname (e.g., example.com or beta.example.com) const hostname = window.location.hostname; // Convert to a safe string for use in cookie names return hostname.replace(/[^a-zA-Z0-9]/g, '_'); } function setCookie(name, value, days = 36500) { // Default to ~100 years (forever) // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; const d = new Date(); d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); const expires = "expires=" + d.toUTCString(); document.cookie = domainSpecificName + "=" + value + ";" + expires + ";path=/"; console.log(`Cookie set: ${domainSpecificName}=${value}, expires: ${d.toUTCString()}`); } function getCookie(name) { // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; const cookieName = domainSpecificName + "="; const decodedCookie = decodeURIComponent(document.cookie); const cookieArray = decodedCookie.split(';'); // console.log(`All cookies: ${document.cookie}`); for (let i = 0; i < cookieArray.length; i++) { let cookie = cookieArray[i]; while (cookie.charAt(0) === ' ') { cookie = cookie.substring(1); } if (cookie.indexOf(cookieName) === 0) { const value = cookie.substring(cookieName.length, cookie.length); console.log(`Cookie found: ${domainSpecificName}=${value}`); return value; } } console.log(`Cookie not found: ${domainSpecificName}`); return ""; } function deleteCookie(name) { // Namespace the cookie name with the current domain const domainSpecificName = `${getCurrentDomain()}_${name}`; document.cookie = domainSpecificName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; console.log(`Cookie deleted: ${domainSpecificName}`); } // Function to show the login modal window.showLoginModal = function () { const modal = document.getElementById('login-modal'); modal.style.display = 'flex'; document.getElementById('user-id').focus(); document.getElementById('login-error').textContent = ''; // Clear previous errors } // Function to hide the login modal window.closeLoginModal = function () { document.getElementById('login-modal').style.display = 'none'; } // Function to handle logout window.handleLogout = function () { isLoggedIn = false; currentUser = null; hashedUser = null; // Update UI updateLoginState(); // Hide settings section document.getElementById('settings-section').classList.add('hidden'); // If currently in settings section, redirect to default section const settingsSection = document.getElementById('settings'); if (settingsSection.style.display === 'block') { showSection('news'); } // Ensure we won't auto login to a named user deleteCookie('userid'); } // Function to handle login from dialog window.handleLogin = async function () { const userId = document.getElementById('user-id').value.trim(); closeLoginModal(); console.log('Attempting login with user ID: ', userId); try { if (await validateUserId(userId)) { console.log('User ID validated successfully.'); await fetchSettings(); console.log('Login successful, updating news feed...'); updateNews(true); // Update news feed after login } } catch (error) { console.error('Error fetching settings: ', error); } } // Manually swap dark/light mode window.toggleMode = function () { console.log('Toggling dark mode manually.'); toggleSetting('auto-dark-mode', false); document.body.classList.toggle('dark-mode'); const darkMode = document.body.classList.contains('dark-mode'); document.getElementById('darkModeToggle').checked = darkMode; toggleSetting('dark-mode', darkMode); updateDarkModeDependants(); } // Function called by the toggle UI elements window.toggleSettingFrom = function(element) { console.log('Toggle setting from UI element.'); // const settingItem = element.closest('.settings-toggle-item'); // Find closest element with a data-setting attribute const settingItem = element.closest('[data-setting]'); if (settingItem && settingItem.dataset.setting) { const key = settingItem.dataset.setting; const value = element.checked; toggleSetting(key, value); } } // Function for toggling option-based settings (like map-choice) window.toggleOptionSetting = function(button) { const settingItem = button.closest('.option-switch-container'); if (!settingItem || !settingItem.dataset.setting) return; const key = settingItem.dataset.setting; let value = button.dataset.value; // Handle special cases for compatibility if (key === 'imperial-units') { // Convert value to boolean value = (value === 'english'); } // Store the setting toggleSetting(key, value); console.log(`Option setting "${key}" changed to "${value}"`); } // Function called by the text input UI elements for text-based settings window.updateSettingFrom = function(element) { const settingItem = element.closest('.settings-text-item'); if (settingItem && settingItem.dataset.setting) { const key = settingItem.dataset.setting; const value = element.value.trim(); toggleSetting(key, value); } } PKc:9X9XPK-G.Z settings.phpSV date('Y-m-d H:i:s'), "version" => "1" ]; // Load .env variables from a JSON file $envFilePath = __DIR__ . '/.env'; if (file_exists($envFilePath)) { $envContent = file_get_contents($envFilePath); $envVariables = json_decode($envContent, true); if (json_last_error() === JSON_ERROR_NONE) { foreach ($envVariables as $key => $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Function to get client IP address accounting for proxies function getClientIP() { if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { // If the site is behind a proxy, get the real client IP $ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0]; } elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) { $ip = $_SERVER['HTTP_CLIENT_IP']; } else { $ip = $_SERVER['REMOTE_ADDR']; } return filter_var($ip, FILTER_VALIDATE_IP) ? $ip : 'unknown'; } // SQL database configuration $dbName = $_ENV['SQL_DB_NAME'] ?? 'teslacloud'; $dbHost = $_ENV['SQL_HOST'] ?? null; $dbUser = $_ENV['SQL_USER'] ?? null; $dbPass = $_ENV['SQL_PASS'] ?? null; $dbPort = $_ENV['SQL_PORT'] ?? '3306'; // Establish database connection if (!$dbHost || !$dbName || !$dbUser) { logMessage("Missing required database configuration", "ERROR"); http_response_code(500); echo json_encode(['error' => 'Database configuration missing']); exit; } // Connect to database try { $dsn = "mysql:host={$dbHost};port={$dbPort};dbname={$dbName};charset=utf8mb4"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; $dbConnection = new PDO($dsn, $dbUser, $dbPass, $options); // Check if the required table exists, create it if not $tableCheck = $dbConnection->query("SHOW TABLES LIKE 'user_settings'"); if ($tableCheck->rowCount() == 0) { $sql = "CREATE TABLE user_settings ( user_id VARCHAR(255) NOT NULL, setting_key VARCHAR({$maxKeyLength}) NOT NULL, setting_value TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id, setting_key) )"; $dbConnection->exec($sql); } // Check if user_ids table exists, create it if not $userIdsTableCheck = $dbConnection->query("SHOW TABLES LIKE 'user_ids'"); if ($userIdsTableCheck->rowCount() == 0) { $sql = "CREATE TABLE user_ids ( user_id VARCHAR(255) NOT NULL, initial_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_login TIMESTAMP DEFAULT CURRENT_TIMESTAMP, login_count INT DEFAULT 0, PRIMARY KEY (user_id) )"; $dbConnection->exec($sql); logMessage("Created user_ids table"); } // Check if login_hist table exists, create it if not $loginHistTableCheck = $dbConnection->query("SHOW TABLES LIKE 'login_hist'"); if ($loginHistTableCheck->rowCount() == 0) { $sql = "CREATE TABLE login_hist ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(255) NOT NULL, login_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ip_address VARCHAR(45) NOT NULL )"; $dbConnection->exec($sql); logMessage("Created login_hist table"); } } catch (PDOException $e) { $errorMessage = "Database connection failed: " . $e->getMessage(); logMessage($errorMessage, "ERROR"); http_response_code(500); echo json_encode(['error' => 'Database connection failed']); exit; } // Parse the request URI to extract user and key $requestUri = $_SERVER['REQUEST_URI']; $uriParts = explode('/', trim(parse_url($requestUri, PHP_URL_PATH), '/')); // Determine which parts of the URL contain our parameters $userId = null; $key = null; // Check if we have enough parts to contain a user ID if (count($uriParts) > 1) { $scriptName = basename(__FILE__); // Should be settings.php $scriptPos = array_search($scriptName, $uriParts); if ($scriptPos !== false && isset($uriParts[$scriptPos + 1])) { $userId = $uriParts[$scriptPos + 1]; // Check if we also have a key // TODO: Just return all the rest of the parts concatenated if (isset($uriParts[$scriptPos + 2])) { $key = $uriParts[$scriptPos + 2]; } } } // Handle the request based on method $method = $_SERVER['REQUEST_METHOD']; switch ($method) { case 'POST': // POST request - create a new user settings resource // Check if userId is valid if ($userId && !validateUserId($userId)) { logMessage("POST: Invalid user ID: $userId", "ERROR"); http_response_code(400); exit; } // Check if user settings already exist if ($userId && userSettingsExist($userId)) { logMessage("POST: User settings already exist for $userId", "WARNING"); http_response_code(409); // Conflict exit; } // Generate userId if none is provided if (!$userId) { logMessage("POST: Creating new user with random ID", "INFO"); $userId = bin2hex(string: random_bytes(length: 4)); // Generate a random user ID $automated = true; } else { $automated = false; } logMessage("POST: Creating user settings for $userId...", "INFO"); if (initializeUserIdEntry(userId: $userId, auto_created: $automated)) { saveUserSettings(userId: $userId, settings: $defaultSettings); // Default settings logMessage("POST: User settings created successfully for $userId", "INFO"); http_response_code(201); // Created echo json_encode([ 'success' => true, 'userId' => $userId, 'auto_generated' => $automated, 'message' => 'User settings created with default values.', 'settings' => $defaultSettings ]); } else { logMessage("POST: Failed to create user settings for $userId", "ERROR"); http_response_code(500); } break; case 'HEAD': // HEAD request - check if user settings exist without returning content if (!$userId || !validateUserId($userId)) { logMessage("HEAD: Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); exit; } if (!userSettingsExist($userId)) { logMessage("HEAD: User settings not found for $userId", "WARNING"); http_response_code(404); exit; } // Update user_ids table - update last_login timestamp and increment login_count initializeUserIdEntry($userId); // Record login in login_hist table recordLogin($userId); // Resource exists, return 200 OK (with no body) http_response_code(200); exit; case 'GET': // GET request - retrieve settings for a user if (!$userId || !validateUserId($userId)) { logMessage("GET: Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); echo json_encode(['error' => 'Invalid or missing user ID in URL path']); exit; } // Check if the user settings exist if (!userSettingsExist($userId)) { logMessage("GET: User settings not found for $userId", "WARNING"); http_response_code(404); exit; } if ($key) { // Check if there's an exact match for the key $exactValue = getSingleSetting($userId, $key); if ($exactValue !== null) { // Key exists, return just this value echo json_encode([$key => $exactValue]); } else { // No exact match, try to get settings with this prefix $settingsWithPrefix = getSettingsWithPrefix($userId, $key); if (!empty($settingsWithPrefix)) { echo json_encode($settingsWithPrefix); } else { logMessage("GET: No settings found with key or prefix '$key' for user $userId", "WARNING"); http_response_code(404); } } } else { // No key provided, return all settings $settings = loadUserSettings($userId); echo json_encode($settings); } break; case 'PUT': // PUT request - update or create a setting if (!$userId || !validateUserId($userId)) { logMessage("Invalid or missing user ID: $userId", "ERROR"); http_response_code(400); exit; } if (!$key) { logMessage("Missing key in URL path", "ERROR"); http_response_code(400); exit; } // Validate key length if (strlen($key) > $maxKeyLength) { logMessage("Key too long: $key", "ERROR"); http_response_code(400); exit; } // Parse the input $requestData = json_decode(file_get_contents('php://input'), true); if (!isset($requestData['value'])) { logMessage("Missing value parameter", "ERROR"); http_response_code(400); exit; } $value = $requestData['value']; // Validate value length if (strlen(json_encode($value)) > $maxValueLength) { logMessage("Value too long", "ERROR"); http_response_code(400); echo json_encode(['error' => 'Value too long']); exit; } // Convert value to boolean if it's a boolean string or already boolean if (is_string($value)) { if ($value === 'true') { $value = true; } elseif ($value === 'false') { $value = false; } // Keep other string values as is - this handles non-boolean settings } // Check if this is a new resource creation $isCreatingResource = !userSettingsExist($userId); // Update the single setting instead of all settings if (updateSingleSetting($userId, $key, $value)) { // Return 201 Created if this was a new resource, otherwise 200 OK if ($isCreatingResource) { http_response_code(201); echo json_encode(['success' => true, 'key' => $key, 'created' => true]); } else { echo json_encode(['success' => true, 'key' => $key]); } } else { logMessage("Failed to save setting $key for user $userId", "ERROR"); http_response_code(500); } break; default: logMessage("Invalid method: $method", "ERROR"); // Method not allowed http_response_code(405); break; } // ***** Utility Functions ***** // Helper function to initialize or update a user entry in the user_ids table function initializeUserIdEntry($userId, $auto_created = false): bool { global $dbConnection; logMessage("Initializing/updating user_ids entry for user $userId"); try { $currentTime = date('Y-m-d H:i:s'); // First check if the user already exists in the user_ids table $checkStmt = $dbConnection->prepare("SELECT 1 FROM user_ids WHERE user_id = ? LIMIT 1"); $checkStmt->execute([$userId]); $userExists = $checkStmt->rowCount() > 0; if ($userExists) { // Update existing user's last_login and increment login_count $updateStmt = $dbConnection->prepare(" UPDATE user_ids SET last_login = ?, login_count = login_count + 1, last_ip = ? WHERE user_id = ? "); $updateStmt->execute([$currentTime, getClientIP(), $userId]); logMessage("Updated login statistics for user $userId"); } else { // Create new user entry with initial values $auto_created_bit = $auto_created ? 1 : 0; $insertStmt = $dbConnection->prepare(" INSERT INTO user_ids (user_id, initial_login, last_login, last_ip, login_count, auto_created) VALUES (?, ?, ?, ?, 1, ?) "); $insertStmt->execute([$userId, $currentTime, $currentTime, getClientIP(), $auto_created_bit]); logMessage("Added user $userId to user_ids table with initial login at $currentTime"); } return true; } catch (PDOException $e) { logMessage("Failed to initialize user_ids entry: " . $e->getMessage(), "WARNING"); // Non-fatal error return false; } } // Helper function to update a single setting function updateSingleSetting($userId, $key, $value) { global $dbConnection; logMessage("Updating single setting for user $userId, key: $key"); try { $jsonValue = json_encode($value); // Check if this key already exists for the user $checkStmt = $dbConnection->prepare("SELECT 1 FROM user_settings WHERE user_id = ? AND setting_key = ? LIMIT 1"); $checkStmt->execute([$userId, $key]); $exists = $checkStmt->rowCount() > 0; if ($exists) { // Update existing key logMessage("Key $key exists, updating it"); $updateStmt = $dbConnection->prepare("UPDATE user_settings SET setting_value = ? WHERE user_id = ? AND setting_key = ?"); $updateStmt->execute([$jsonValue, $userId, $key]); } else { // Insert new key logMessage("Key $key does not exist, inserting it"); $insertStmt = $dbConnection->prepare("INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES (?, ?, ?)"); $insertStmt->execute([$userId, $key, $jsonValue]); } logMessage("Successfully saved setting $key for user $userId"); return true; } catch (PDOException $e) { $errorMsg = "Database error updating setting: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); return false; } } // Helper function to get a single setting value function getSingleSetting($userId, $key) { global $dbConnection; logMessage("Getting single setting for user $userId, key: $key"); try { // Get the specific key value $stmt = $dbConnection->prepare("SELECT setting_value FROM user_settings WHERE user_id = ? AND setting_key = ?"); $stmt->execute([$userId, $key]); if ($stmt->rowCount() > 0) { $row = $stmt->fetch(); $value = json_decode($row['setting_value'], true); logMessage("Found setting $key for user $userId"); return $value !== null ? $value : $row['setting_value']; } else { logMessage("Setting $key not found for user $userId", "WARNING"); return null; } } catch (PDOException $e) { $errorMsg = "Database error getting setting: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; } } // Helper function to get settings with a prefix function getSettingsWithPrefix($userId, $keyPrefix) { global $dbConnection; logMessage("Getting settings with prefix '$keyPrefix' for user $userId"); try { $stmt = $dbConnection->prepare("SELECT setting_key, setting_value FROM user_settings WHERE user_id = ? AND setting_key LIKE ?"); $stmt->execute([$userId, $keyPrefix . '%']); $settings = []; while ($row = $stmt->fetch()) { $value = json_decode($row['setting_value'], true); $settings[$row['setting_key']] = $value !== null ? $value : $row['setting_value']; } logMessage("Found " . count($settings) . " setting(s) with prefix '$keyPrefix' for user $userId"); return $settings; } catch (PDOException $e) { $errorMsg = "Database error getting settings with prefix: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; } } // Helper function to validate user ID function validateUserId($userId) { $isValid = (strlen($userId) >= 8) && preg_match('/^[a-zA-Z0-9_-]+$/', $userId); logMessage("Validating user ID: $userId - " . ($isValid ? "Valid" : "Invalid")); return $isValid; } // Helper function to check if user settings exist function userSettingsExist($userId) { global $dbConnection; try { logMessage("Checking if user $userId exists in database"); $stmt = $dbConnection->prepare("SELECT 1 FROM user_settings WHERE user_id = ? LIMIT 1"); $stmt->execute([$userId]); $exists = $stmt->rowCount() > 0; logMessage("Database check result: " . ($exists ? "User exists" : "User does not exist")); return $exists; } catch (PDOException $e) { $errorMsg = "Database error checking if user settings exist: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; // Rethrow the exception after logging } } // Helper function to load user settings function loadUserSettings($userId) { global $dbConnection, $defaultSettings; logMessage("Loading settings for user $userId"); try { logMessage("Loading settings from database for $userId"); $settings = []; $stmt = $dbConnection->prepare("SELECT setting_key, setting_value FROM user_settings WHERE user_id = ?"); $stmt->execute([$userId]); $rowCount = $stmt->rowCount(); logMessage("Found $rowCount setting(s) in database for user $userId"); if ($rowCount > 0) { while ($row = $stmt->fetch()) { // Parse stored JSON value or use as is if parsing fails $value = json_decode($row['setting_value'], true); $settings[$row['setting_key']] = ($value !== null) ? $value : $row['setting_value']; logMessage("Loaded setting {$row['setting_key']} from database with value type: " . gettype($value)); } return $settings; } // If no settings in DB but this was called, create default settings logMessage("No settings found in database, saving defaults", "WARNING"); saveUserSettings($userId, $defaultSettings); return $defaultSettings; } catch (PDOException $e) { $errorMsg = "Database error loading user settings: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); throw $e; // Rethrow the exception after logging } } // Helper function to save user settings function saveUserSettings($userId, $settings) { global $dbConnection; logMessage("Saving settings for user $userId - " . count($settings) . " setting(s)"); try { logMessage("Saving settings to database"); $dbConnection->beginTransaction(); // Delete existing settings for this user $deleteStmt = $dbConnection->prepare("DELETE FROM user_settings WHERE user_id = ?"); $deleteStmt->execute([$userId]); $deletedCount = $deleteStmt->rowCount(); logMessage("Deleted $deletedCount existing setting(s) for user $userId"); // Insert new settings $insertStmt = $dbConnection->prepare("INSERT INTO user_settings (user_id, setting_key, setting_value) VALUES (?, ?, ?)"); $insertCount = 0; foreach ($settings as $key => $value) { $jsonValue = json_encode($value); $insertStmt->execute([$userId, $key, $jsonValue]); $insertCount++; logMessage("Inserted setting: $key with value type: " . gettype($value)); } $dbConnection->commit(); logMessage("Database transaction committed successfully - inserted $insertCount setting(s)"); return true; } catch (PDOException $e) { $dbConnection->rollBack(); $errorMsg = "Database error saving user settings: " . $e->getMessage(); logMessage($errorMsg, "ERROR"); return false; } } // Helper function to record login attempts function recordLogin($userId) { global $dbConnection; logMessage("Recording login for user $userId"); try { $stmt = $dbConnection->prepare("INSERT INTO login_hist (user_id, login_time, ip_address) VALUES (?, ?, ?)"); $stmt->execute([$userId, date('Y-m-d H:i:s'), getClientIP()]); logMessage("Recorded login for user $userId"); } catch (PDOException $e) { logMessage("Failed to record login for user $userId: " . $e->getMessage(), "WARNING"); // Non-fatal error } } // Simple logging function function logMessage($message, $level = 'INFO') { global $logFile; $timestamp = date('Y-m-d H:i:s'); $formattedMessage = "[$timestamp] [$level] $message" . PHP_EOL; file_put_contents($logFile, $formattedMessage, FILE_APPEND); } PK#V#VPK-m.Z share.phpSV $value) { $_ENV[$key] = $value; } } else { error_log("Failed to parse .env file: " . json_last_error_msg()); } } else { error_log(".env file not found at $envFilePath"); } // Check if API key is set if (!isset($_ENV['SENDGRID_KEY'])) { http_response_code(500); echo json_encode(['error' => 'API key not found in .env file']); exit; } // Only allow POST requests if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); echo 'Method Not Allowed'; exit; } // Parse JSON payload $input = json_decode(file_get_contents('php://input'), true); if (!$input || !isset($input['to']) || !isset($input['html'])) { http_response_code(400); echo 'Invalid payload'; exit; } // TODO: Get e-mail address from settings database by passing in hashed user ID $to = $input['to']; $subject = $input['subject'] ?? 'Article forwarded from teslas.cloud'; $htmlContent = $input['html']; $email = new \SendGrid\Mail\Mail(); $email->setFrom("feedback@birgefuller.com", "Birge & Fuller, LLC"); $email->setSubject($subject); $email->addTo($to); $email->addContent("text/plain", strip_tags($htmlContent)); $email->addContent("text/html", $htmlContent); $sendgrid = new \SendGrid($_ENV['SENDGRID_KEY']); try { $response = $sendgrid->send($email); print $response->statusCode() . "\n"; print_r($response->headers()); print $response->body() . "\n"; } catch (Exception $e) { echo 'Caught exception: '. $e->getMessage() ."\n"; } PK6HHPK-m.Z share.svgSV PK0PK-$Z styles.cssSV/* Variables */ :root { --bg-color: #efefef; --text-color: #777777; --active-section-bg: white; --separator-color: #cccccc; --button-bg: #dddddd; --button-text: #333333; --button-highlight: #f0f0f0; --info-bg: #d0d0d0; --tesla-blue: #0077ff; --tesla-red: #ff0000; --button-radius: 13px; --weather-switch-slider: #ffffff; --weather-warning-color: #ff7700; --status-poor: #ff9100; /* Yellow for poor connection */ --status-good: #00d000; /* Green for good connection */ --status-unavailable: #888888; /* Gray for unavailable/bad connection */ --status-rain: #00a1ff; /* Blue for rain indicator */ --heading-font-size: 17pt; --heading-font-weight: 700; } body.dark-mode { --bg-color: #1d1d1d; --text-color: #777777; --active-section-bg: #333333; --separator-color: #444444; --button-bg: #333333; --button-text: #d6d6d6; --button-highlight: #bdbdbd; --info-bg: #302f39; --weather-switch-slider: #222222; --weather-warning-color: #ff9900; --status-poor: #ffcd27; /* Yellow for poor connection */ --status-good: #5eff19; /* Green for good connection */ --status-unavailable: #888888; /* Gray for unavailable/bad connection */ --status-rain: #4fb8ff; /* Blue for rain indicator */ } /* Basic HTML Elements */ * { font-family: "Inter"; font-optical-sizing: auto; font-variant-ligatures: all; } body { background-color: var(--bg-color); color: var(--text-color); font-size: 16pt; padding: 0; text-align: left; font-weight: 500; } a { color: var(--tesla-blue); text-decoration: none; font-weight: 575; } h1 { color: var(--button-text); font-size: 19pt; margin-bottom: 15px; text-align: left; margin-top: 0px; font-weight: 250; } h2 { color: var(--button-text); font-size: var(--heading-font-size); margin-bottom: 9px; margin-top: 28px; font-weight: var(--heading-font-weight); } h3 { color: var(--button-text); font-size: calc(var(--heading-font-size) - 1pt); font-weight: calc(var(--heading-font-weight) - 100); margin-bottom: 7px; margin-top: 16px; } p { margin-top: 12px; margin-bottom: 12px; text-align: justify; max-width: 980px; } ul { padding-left: 25px; margin-top: 0px; margin-bottom: 0px; text-align: justify; max-width: 940px; } li { margin: 11px 0; padding-left: 0px; margin-left: 0px; } hr { border: 0; border-top: 1px solid var(--separator-color); margin-top: 28px; margin-bottom: 28px; } /* About */ .announcement p { background-color: #03a8f422; color: var(--button-text); border-radius: var(--button-radius); padding: 18px; font-style: none; font-weight: 700; text-align: center; /* text-transform: uppercase; */ max-width: 840px; place-self: left; } /* Layout */ .frame-container { display: flex; height: 100vh; width: 100%; position: absolute; top: 0; left: 0; overflow: hidden; /* Prevent scrolling on the frame-container */ } .left-frame { width: 300px; flex-shrink: 0; /* height: 100%; */ overflow-y: auto; padding: 10px 10px 5px 15px; box-sizing: border-box; /* Include padding in height calculations */ scrollbar-gutter: stable; scrollbar-width: thin; } .right-frame { flex-grow: 1; height: 100%; /* Ensure it spans the full height of the container */ overflow-y: auto; /* Enable scrolling for the right frame */ padding: 20px 45px 20px 15px; box-sizing: border-box; /* Include padding in the height calculation */ /* scrollbar-gutter: stable; */ scrollbar-width: thin; /* Use a thin scrollbar */ position: relative; /* Add positioning context for the absolute positioned external-site */ } .section { display: none; } .hidden { display: none; } /* External site container */ #external-site { position: absolute; top: 0; left: 0; right: 0; bottom: 0; width: 100%; height: 100%; display: none; overflow: hidden; padding: 0; margin: 0; } #external-site iframe { width: 100%; height: 100%; border: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; padding: 0; margin: 0; } /* Navigation */ .section-buttons { position: relative; display: flex; flex-direction: column; margin: 0; padding: 0; width: 250px; /* min-width: 250px; */ max-height: 100%; /* Ensure it doesn't exceed the container height */ /* overflow-y: auto; */ box-sizing: border-box; /* Include padding in height calculations */ } .section-button { color: var(--text-color); border-radius: var(--button-radius); background-color: transparent; font-size: 19pt; font-weight: 675; letter-spacing: 0.02em; padding: 16px 20px; border: none; cursor: pointer; text-align: left; margin-bottom: 5px; display: flex; align-items: center; transition: background-color 0.3s, color 0.3s; } .logout-button { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); font-size: 16pt; font-weight: 600; padding: 16px 20px; border: none; cursor: pointer; text-align: center; margin-top: 15px; width: auto; transition: background-color 0.3s; } .logout-button:hover { background-color: var(--active-section-bg); } .button-icon { width: 20px; height: 20px; margin-right: 12px; stroke: currentColor; flex-shrink: 0; } .section-button.active { background-color: var(--active-section-bg); color: var(--button-text); } .section-button:hover { background-color: var(--active-section-bg); } /* Notification dot for news section */ .section-button.has-notification { position: relative; } .section-button.has-notification::after { content: ""; position: absolute; top: 50%; /* Center vertically */ left: 5px; /* Moved further left from 10px */ transform: translateY(-50%); /* Perfect vertical centering */ width: 10px; height: 10px; background-color: var(--tesla-blue); border-radius: 50%; animation: pulse 2s 1; /* Changed from 3 pulses to 1 */ animation-fill-mode: forwards; /* Keep final state */ } @keyframes pulse { 0% { transform: translateY(-50%) scale(0.6); opacity: 0.6; } 50% { transform: translateY(-50%) scale(1.5); /* Larger pulse */ opacity: 1; } 100% { transform: translateY(-50%) scale(1); opacity: 1; } } /* Link Button Lists */ .button-list { list-style: none; padding: 0; margin-top: 0; display: grid; grid-template-columns: repeat(auto-fill, 280px); gap: 16px; justify-content: start; max-width: calc(280px * 5 + 16px * 4); /* Limit to 5 columns */ } .button-list li { margin: 0; } .button-list a { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); display: flex; padding-top: 24px; padding-bottom: 24px; padding-left: 9px; padding-right: 9px; transition: background-color 0.3s; width: auto; font-weight: 600; height: 40px; align-items: center; justify-content: center; gap: 10px; text-align: center; } .button-list a img { height: 36px; width: auto; border-radius: 7px; } /* Enhanced Image Visibility Classes */ .img-adaptive, .img-adaptive-light-invert, .img-adaptive-dark-invert { /* Base styling shared by all image adaptive classes */ filter: grayscale(100%) contrast(1.5); transition: filter 0.3s; } /* Invert colors only in light mode */ body:not(.dark-mode) .img-adaptive, body:not(.dark-mode) .img-adaptive-light-invert { filter: invert(100%); } /* Invert colors only in dark mode */ body.dark-mode .img-adaptive-dark-invert { filter: invert(100%); } /* Make white areas in images transparent */ .img-white-transparent { /* Remove the inverting filters */ mix-blend-mode: multiply; /* Keep this to make white transparent */ transition: filter 0.3s; } /* Add specific dark mode handling */ body.dark-mode .img-white-transparent { mix-blend-mode: screen; /* In dark mode, use screen blend mode to make white transparent */ filter: invert(100%) brightness(125%); /* Invert colors and slightly increase brightness for better visibility */ } /* Indicator container and dark-mode toggle */ .control-container { position: fixed; top: -5px; /* Changed from 10px to -5px to move above screen edge */ right: 20px; display: flex; align-items: center; background-color: var(--bg-color); /* Semi-transparent light mode background */ border-radius: 5px; padding: 8px 12px; z-index: 100; /* Ensure it stays above other content */ opacity: 0.75; } .toggle-label { color: var(--text-color); font-size: 15pt; font-weight: 600; margin-right: 10px; } .toggle-switch { position: relative; display: inline-block; width: 48px; /* Reduced by 20% from 60px */ height: 27px; /* Reduced by 20% from 34px */ } .toggle-switch input { opacity: 0; width: 0; height: 0; } .toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #bbb; transition: .4s; border-radius: 34px; } .toggle-slider:before { position: absolute; content: ""; height: 21px; /* Reduced by 20% from 26px */ width: 21px; /* Reduced by 20% from 26px */ left: 3px; /* Adjusted from 4px */ bottom: 3px; /* Adjusted from 4px */ background-color: white; transition: .4s; border-radius: 50%; } input:checked + .toggle-slider { background-color: #2196F3; } input:focus + .toggle-slider { box-shadow: 0 0 1px #2196F3; } input:checked + .toggle-slider:before { transform: translateX(21px); /* Reduced by 20% from 26px */ } /* Data Display */ .data-info, .data-info-column { row-gap: 18px; /* max-width: 1080px; */ width: auto; /* Ensure it spans full width */ } .data-info { display: grid; grid-template-columns: repeat(auto-fill, 240px); column-gap: 28px; } .data-info-column { margin-top: 12px; display: flex; flex-direction: column; } .data-info-column .data-item { margin-right: 0; /* Ensure no extra margin */ width: auto; /* Ensure items span full width */ } .data-item { color: var(--button-text); font-weight: 600; font-size: var(--heading-font-size); display: block; margin-right: 0; padding-top: 0px; } .data-item:last-child { margin-right: 0; margin-bottom: 0; } .data-item h2 { color: var(--text-color); margin-top: 0px; margin-bottom: 4px; } /* Stats */ .nav-container { display: flex; gap: 50px; margin: 30px 0px -25px 0px; align-items: flex-start; } .nav-stats { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, auto); gap: 10px 0 0 0; width: 400px; } .stat-box { display: flex; flex-direction: column; align-items: left; min-width: 200px; height: 125px; /* fixed height added */ } .value-container { display: flex; align-items: center; /* Vertically align items in container */ } .stat-value { color: var(--button-text); font-size: 48pt; font-weight: 600; display: inline; font-variant-numeric: tabular-nums; } .stat-unit { color: var(--text-color); font-size: 24pt; font-weight: 650; font-family: sans-serif; display: inline; margin-left: 5px; /* Add some spacing between value and unit */ } .stat-label { color: var(--text-color); font-size: 13pt; font-weight: 650; margin-top: 0px; display: block; /* Ensure it's on its own line */ clear: both; /* Clear the float to ensure it appears below */ } /* Dashboard Display */ .radar-container { position: relative; padding-top: 30px; /* Added padding to push radar down */ margin-right: 30px; } .radar-title { position: absolute; top: 0px; left: 24px; text-align: center; margin: 0; } #radarDisplay { border-radius: 50%; background-color: rgba(0, 0, 0, 0.1); } .dark-mode #radarDisplay { background-color: rgba(255, 255, 255, 0.1); } /* Waze frame */ .teslawaze-container { height: calc(100vh - 370px); min-height: 280px; margin-bottom: 0; /* Ensure no bottom margin */ position: relative; /* Add positioning context */ } #teslawaze { border-radius: var(--button-radius); width: 100%; height: 100%; border: none; } /* News Elements */ .news-headlines { margin: 24px 0; } .news-item { background-color: var(--button-bg); border-radius: var(--button-radius); margin-bottom: 15px; padding: 9px 9px 9px 61px; max-width: 1200px; cursor: pointer; position: relative; /* Added for absolute positioning of the favicon */ } .news-new { position: relative; } .news-new::after { content: ""; position: absolute; top: 50%; right: 15px; transform: translateY(-50%); width: 8px; height: 8px; background-color: var(--tesla-blue); border-radius: 50%; animation: news-pulse 1.5s 1; animation-fill-mode: forwards; } @keyframes news-pulse { 0% { opacity: 0.7; transform: translateY(-50%) scale(0.7); } 50% { opacity: 1; transform: translateY(-50%) scale(1.3); } 100% { opacity: 1; transform: translateY(-50%) scale(1); } } .news-item:hover { background-color: var(--button-bg); /* Keep the hover color consistent */ } .news-item:last-child { margin-bottom: 0; } .news-source { font-weight: 675; margin-right: 10px; font-size: 11.5pt; } .news-time, .news-date { font-size: 12.5pt; margin-left: 5px; font-weight: 500; } .news-title { color: var(--button-text); margin-top: 0px; padding-bottom: 5px; margin-right: 55px; /* avoid indicators */ font-size: 16pt; font-weight: 550; } .news-favicon { position: absolute; /* Position absolutely within the news-item */ left: 15px; /* Position on the left side with some padding */ top: 50%; /* Center vertically */ transform: translateY(-50%); /* Perfect vertical centering */ width: 32px; /* Slightly increased width */ height: 32px; /* Slightly increased height */ border-radius: 4px; /* Optional: adds a slight rounding to the icons */ opacity: 0.8; } .share-icon { position: absolute; right: 21px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; } body.dark-mode .share-icon img { filter: brightness(0) invert(0.8) grayscale(1); } /* Status Indicators - Unified Styling */ .status-indicator { width: 24px; height: 24px; margin-right: 22px; display: flex; align-items: center; justify-content: center; transition: color 0.3s ease; } /* Network Status Indicator */ .network-status { color: var(--status-unavailable); } .network-status.unavailable { color: var(--status-unavailable); } .network-status.poor { color: var(--status-poor); } .network-status.good { color: var(--status-good); } /* GPS Status Indicator */ .gps-status { color: var(--status-unavailable); } .gps-status.unavailable { color: var(--status-unavailable); } .gps-status.poor { color: var(--status-poor); } .gps-status.good { color: var(--status-good); } /* Rain Status Indicator */ .rain-status { color: var(--weather-warning-color); /* Changed to orange weather warning color */ animation: pulse-rain 2s infinite; } @keyframes pulse-rain { 0% { opacity: 0.7; } 50% { opacity: 1; } 100% { opacity: 0.7; } } /* Notification System */ #notification-container { position: fixed; top: 20px; left: 50%; /* Center horizontally */ transform: translateX(-50%); /* Offset by half width for true centering */ z-index: 9999; max-width: 80%; /* Allow more width */ min-width: 200px; } .notification { display: flex; align-items: center; background-color: rgba(0, 0, 0, 0.75); padding: 12px 16px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); opacity: 0; transform: translateY(-20px); transition: opacity 0.3s, transform 0.3s; white-space: nowrap; /* Keep on one line */ width: auto; /* Size to content */ display: inline-flex; /* Only take needed width */ } .notification.show { opacity: 1; transform: translateY(0); } .notification.hide { opacity: 0; transform: translateY(-20px); } .notification-icon { margin-right: 12px; color: var(--weather-warning-color); /* Match the orange warning color */ } .notification-message { font-size: 14pt; font-weight: 500; color: #ff9500; /* Bright orange color for text */ white-space: nowrap; /* Ensure text stays on one line */ } /* Spinner styling */ .spinner-container { display: flex; justify-content: center; align-items: center; width: 100%; height: 200px; /* Provide adequate height for the spinner */ } .spinner { width: 50px; height: 50px; border: 5px solid rgba(0, 119, 255, 0.2); /* Tesla blue with opacity */ border-radius: 50%; border-top-color: var(--tesla-blue); animation: spin 1s ease-in-out infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Network status indicator */ .network-status svg { width: 24px; height: 24px; } .network-status .network-bar { transition: fill 0.3s ease; } .network-status.unavailable .network-bar { fill: var(--status-unavailable); } .network-status.poor .bar-1 { fill: var(--status-poor); } .network-status.poor .bar-2, .network-status.poor .bar-3, .network-status.poor .bar-4 { fill: var(--status-unavailable); } .network-status.fair .bar-1, .network-status.fair .bar-2 { fill: var(--status-poor); } .network-status.fair .bar-3, .network-status.fair .bar-4 { fill: var(--status-unavailable); } .network-status.good .bar-1, .network-status.good .bar-2, .network-status.good .bar-3 { fill: var(--status-good); } .network-status.good .bar-4 { fill: var(--status-unavailable); } .network-status.excellent .bar-1, .network-status.excellent .bar-2, .network-status.excellent .bar-3, .network-status.excellent .bar-4 { fill: var(--status-good); } /* GPS Status Indicator */ .gps-status { margin-right: 22px; /* Good separation from the Dark Mode text */ width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; transition: color 0.3s ease; } .gps-status.unavailable { color: var(--status-unavailable); /* Using the shared variable */ } .gps-status.poor { color: var(--status-poor); /* Using the shared variable */ } .gps-status.good { color: var(--status-good); /* Using the shared variable */ } /* Login Styles */ .login-button { color: var(--button-text); background-color: var(--button-bg); border-radius: var(--button-radius); font-size: 16pt; font-weight: 600; padding: 16px 20px; border: none; cursor: pointer; text-align: center; margin-top: 15px; width: 100%; transition: background-color 0.3s; } .login-button:hover { background-color: var(--active-section-bg); } /* Modal Login Dialog */ .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.6); justify-content: center; align-items: center; } .modal-content { font-size: 14pt; background-color: var(--bg-color); border-radius: var(--button-radius); padding: 25px; padding-top: 0px; max-width: 90%; box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.75); } /* Login form */ .login-form { margin-top: 20px; } .login-form label { font-weight: 500; font-size: 14pt; width: 80px; margin-right: 15px; margin-bottom: 10px; color: var(--button-text); vertical-align: middle; } .login-form input { font-size: 16px; width: calc(100% - 90px); padding: 12px 15px; border: 2px solid var(--separator-color); border-radius: 8px; background-color: var(--active-section-bg); color: var(--button-text); box-sizing: border-box; vertical-align: middle; } .button-container { display: flex; justify-content: flex-end; gap: 10px; } .modal-button { padding: 12px 25px; border: none; border-radius: 8px; font-size: 16px; font-weight: 600; cursor: pointer; transition: background-color 0.3s; } .modal-button.cancel { background-color: var(--button-bg); color: var(--button-text); } .modal-button.submit { background-color: var(--tesla-blue); color: white; } .error-message { color: var(--tesla-red); margin-bottom: 20px; font-size: 14px; min-height: 20px; } /* Settings Section Styles */ .settings-controls { margin-top: -9px; display: grid; grid-template-columns: repeat(auto-fill, 470px); gap: 0px 11px; /* row gap, column gap */ } .settings-toggle-item { display: flex; align-items: center; background-color: var(--button-bg); border-radius: var(--button-radius); padding-left: 15px; padding-right: 15px; padding-top: 12px; padding-bottom: 12px; margin-top: 9px; max-width: 480px; font-weight: 550; cursor: pointer; /* Add cursor pointer to indicate it's clickable */ } .settings-toggle-item label { flex-grow: 1; /* Make label take up all available space */ cursor: inherit; } .settings-toggle-item input { opacity: 0; /* Hide the checkbox */ width: 0; height: 0; position: absolute; } .settings-toggle-item span.settings-toggle-slider { position: relative; display: inline-block; width: 60px; height: 32px; flex-shrink: 0; /* Prevent slider from shrinking */ } .settings-toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #aaa; transition: .4s; border-radius: 30px; } .settings-toggle-slider:before { position: absolute; content: ""; height: 26px; width: 26px; left: 4px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } .settings-description { margin-top: 2pt; font-size: 12.5pt; font-style: italic; margin-left: 11pt; text-align: right; } input:checked + .settings-toggle-slider { background-color: var(--tesla-blue); } input:focus + .settings-toggle-slider { box-shadow: 0 0 1px var(--tesla-blue); } input:checked + .settings-toggle-slider:before { transform: translateX(26px); } /* Settings Text Item */ .settings-text-item { cursor: initial; /* Prevent cursor change on text input */ } .settings-text-item input[type="text"] { opacity: 100%; width: 320px; height: 19px; position: relative; padding: 8px 14px; border-width: 0px; border-radius: 9px; background: var(--bg-color); color: var(--button-text); font-size: 14pt; font-weight: 500; text-align: right; } body.dark-mode .settings-text-item input[type="text"]:disabled { background: #333333; color: #555555; } .settings-text-item input[type="text"]:focus { outline: var(--tesla-blue) solid 2px; } #in-map-toggle { position: absolute; margin: 0px; padding: 7px; z-index: 10; background-color: transparent; opacity: 80%; } #in-map-toggle .option-switch { background-color: var(--button-bg); } #in-map-toggle .option-button { font-size: 12pt; font-weight: 700; } #in-map-toggle { position: absolute; margin: 0px; padding: 7px; z-index: 10; background-color: transparent; opacity: 80%; } #in-map-toggle .option-switch { background-color: var(--button-bg); } #in-map-toggle .option-button { font-size: 12pt; font-weight: 700; } /* Option Switch (Two-option selector) */ .option-switch-container { padding-right: 15px; /* Ensure there's adequate right padding */ padding-top: 11px; padding-bottom: 11px; cursor: initial; } .option-switch { display: flex; border-radius: calc(var(--button-radius) - 2px); overflow: hidden; background-color: var(--button-highlight); } .option-button { flex: 1; background: none; border: none; padding: 8px 14px; color: var(--text-color); font-size: 14pt; font-weight: 600; cursor: pointer; transition: background-color 0.3s, color 0.3s; } .option-button.active { background-color: var(--tesla-blue); color: white; } .option-button:hover:not(.active) { background-color: rgba(0, 0, 0, 0.05); } body.dark-mode .option-button:hover:not(.active) { background-color: rgba(255, 255, 255, 0.1); } .settings-toggle-item.option-switch-container { display: flex; justify-content: space-between; align-items: center; } /* News Source Grid */ .news-source-grid { grid-template-columns: repeat(auto-fill, 310px); gap: 0px 10px; width: auto; } .news-toggle-item { padding: 9px 15px; margin-bottom: 0px; margin-top: 7px; max-width: none; width: auto; border-radius: 9px; } .news-toggle-item { font-size: 15pt; } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { :root { --heading-font-size: 14pt; --heading-font-weight: 600; --button-radius: 10px; } body { font-size: 13pt; } h1 { font-size: 16pt; margin-bottom: 5px; } h2 { margin-top: 15px; margin-bottom: 7px; } /* Layout adjustment */ .frame-container { flex-direction: column; } /* Convert left menu to horizontal top menu */ .left-frame { width: 100%; height: auto; max-height: 90px; padding: 5px; overflow-y: hidden; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; /* Hide scrollbar for Firefox */ } .left-frame::-webkit-scrollbar { display: none; /* Hide scrollbar for Chrome/Safari */ } /* Convert section buttons to horizontal layout */ .section-buttons { flex-direction: row; min-width: min-content; width: auto; height: 80px; overflow-x: auto; } .section-buttons h1 { display: none; /* Hide title to save space */ } .section-button { font-size: 14pt; padding: 8px 15px; margin: 0 3px; white-space: nowrap; height: 60px; } .button-icon { width: 16px; height: 16px; margin-right: 8px; } /* Right frame takes remaining space */ .right-frame { height: calc(100vh - 90px); padding: 10px 15px; } /* Dashboard adjustments */ .nav-container { gap: 20px; margin: 10px 0px -15px 0px; } .radar-container { padding-top: 20px; margin-right: 15px; } #radarDisplay { width: 150px; height: 150px; } .stat-box { height: 90px; } .stat-value { font-size: 36pt; } .stat-unit { font-size: 18pt; } .stat-label { font-size: 11pt; } /* Teslawaze container adjustment */ .teslawaze-container { height: calc(100vh - 300px); min-height: 200px; } /* Button lists */ .button-list { grid-template-columns: repeat(auto-fill, 220px); gap: 10px; } .button-list a { padding: 15px 5px; height: 30px; font-size: 13pt; } /* News items */ .news-item { padding: 8px 8px 8px 50px; } .news-favicon { width: 26px; height: 26px; } .news-title { font-size: 14pt; margin-right: 40px; } /* Settings adjustments */ .settings-controls { grid-template-columns: repeat(auto-fill, 300px); } .settings-toggle-item { padding: 8px 10px; font-size: 13pt; } .settings-toggle-item span.settings-toggle-slider { width: 50px; height: 26px; } .settings-toggle-slider:before { height: 20px; width: 20px; } input:checked + .settings-toggle-slider:before { transform: translateX(22px); } /* News sources grid */ .news-source-grid { grid-template-columns: repeat(auto-fill, 240px); } .news-toggle-item { font-size: 13pt; } /* Control container (top right) */ .control-container { top: 85px; right: 10px; padding: 5px 10px; } .toggle-label { font-size: 13pt; } /* Login button */ .login-button { margin-top: 10px; padding: 10px 15px; font-size: 14pt; } } PK7)ssPK-$Z timeline.cssSV/* Timeline-based hourly forecast styles */ /* Day heading alignment */ .forecast-popup h2 { margin-left: 15pt; } /* Container for the timeline view */ .timeline-container { position: relative; width: 100%; height: 120px; margin-bottom: 40px; user-select: none; } /* Weather condition row */ .timeline-weather-row { display: flex; height: 80px; width: 100%; position: relative; border-radius: var(--button-radius); overflow: hidden; } /* Individual hour in the timeline */ .timeline-hour { flex: 1; height: 100%; position: relative; box-sizing: border-box; } /* Use the existing weather condition gradients */ .timeline-hour.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .timeline-hour.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .timeline-hour.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .timeline-hour.storm, .timeline-hour.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .timeline-hour.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } .timeline-hour:hover { filter: brightness(1.1); } /* Weather icons */ .weather-icons { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; } .weather-change-icon { position: absolute; background-color: rgba(255, 255, 255, 0.5); border-radius: 50%; padding: 2px; box-shadow: 0 0 5px rgba(0, 0, 0, 0.2); z-index: 10; transform: translate(-50%, -50%); top: 40px; /* Exact center of the 80px height timeline */ } .weather-change-icon img { width: 40px; height: 40px; vertical-align: middle; } /* Temperature indicators */ .temperature-indicators { position: absolute; bottom: 0; left: 0; width: 100%; height: 0; pointer-events: none; } .temp-indicator { position: absolute; text-align: center; font-weight: 650; transform: translateX(-50%); color: var(--text-color); bottom: 10px; font-size: 14pt; /* Bumped up font size for temperature labels */ } /* Hour labels */ .hour-labels { position: absolute; bottom: 5px; /* Move up even closer to the temperature labels */ left: 0; width: 100%; pointer-events: none; font-weight: 300; } .hour-label { position: absolute; text-align: center; transform: translateX(-50%); color: var(--text-color); font-size: 13pt; /* Bumped up font size for time labels */ } /* Weather legend */ .weather-legend { display: flex; /* flex-wrap: wrap; */ justify-content: center; gap: 10px; /* margin-top: 15px; */ } .legend-item { display: flex; /* align-items: center; */ margin: 0 10px; font-size: 14pt; font-weight: 500; } .legend-color { width: 35px; height: 25px; margin-right: 7px; border-radius: 3px; } .legend-item.clear .legend-color { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .legend-item.clouds .legend-color { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .legend-item.rain .legend-color { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .legend-item.storm .legend-color, .legend-item.thunderstorm .legend-color { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .legend-item.snow .legend-color { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { .timeline-container { height: 100px; margin-bottom: 35px; } .timeline-weather-row { height: 60px; } .weather-change-icon img { width: 24px; height: 24px; } .temperature-indicators { top: 65px; } .temp-indicator, .hour-label { font-size: 10pt; } .weather-legend { gap: 5px; margin-top: 10px; } .legend-item { margin: 0 5px; font-size: 10pt; } .legend-color { width: 16px; height: 16px; } } PKzsPK-G.Zvers.phpSV $gitInfo['commit'], 'branch' => $gitInfo['branch'], 'tag' => $gitInfo['tag'] ]); PKY#  PK-G.Zwarn.svgSV PKjjPK-Zwx.cssSV:root { --sky-clear-top: #e6f7ff; --sky-clear-bottom: #a8d0f0; --sky-cloudy-top: #cacaca; --sky-cloudy-bottom: #70a0c7; --sky-rainy-top: #7a7f8d; --sky-rainy-bottom: #cacaca; --sky-storm-top: #c9d6e2; --sky-storm-bottom: #7b8a9a; --sky-snow-top: #f0f5fb; --sky-snow-bottom: #d8dfe6; } body.dark-mode { --sky-clear-top: #0a1020; --sky-clear-bottom: #441a45; --sky-cloudy-top: #262729; --sky-cloudy-bottom: #142236; --sky-rainy-top: #2a3040; --sky-rainy-bottom: #262729; --sky-storm-top: #080e18; --sky-storm-bottom: #292b2e; --sky-snow-top: #3e3e3b; --sky-snow-bottom: #706e6b; } /* Weather Switch Elements */ .weather-switch-container { display: flex; justify-content: flex-start; margin: 20px 0; } .weather-switch { display: flex; background-color: var(--button-bg); border-radius: var(--button-radius); padding: 4px; gap: 4px; position: relative; width: 420px; } .weather-switch button { flex: 1; padding: 16px 24px; border: none; border-radius: calc(var(--button-radius) - 4px); background: transparent; color: var(--text-color); cursor: pointer; font-family: 'Inter', sans-serif; font-size: 16pt; font-weight: 600; position: relative; z-index: 1; transition: color 0.3s ease; } .weather-switch button.active { color: var(--button-text); } .weather-switch::after { content: ''; position: absolute; top: 4px; left: 4px; bottom: 4px; width: calc((100% - 8px) / 3); background-color: var(--weather-switch-slider); border-radius: calc(var(--button-radius) - 4px); transform: translateX(calc(var(--slider-position) * 100%)); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .weather-image { width: 95%; max-width: 1080px; opacity: 0; display: none; border-radius: var(--button-radius); transition: opacity 0.3s ease; } .weather-image.active { display: block; opacity: 1; } /* Weather Forecast */ .forecast-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(142px, 1fr)); gap: 15px; margin: 18px 0; max-width: 1100px; } .forecast-day { border-radius: var(--button-radius); padding: 15px; text-align: center; position: relative; min-width: 120px; max-width: 180px; } .hourly-avail { /* box-shadow: 5px 5px 9px 0px rgba(0, 0, 0, 0.5); */ border-color: var(--tesla-blue); border-width: 2.5pt; border-style: solid; cursor: pointer; } #wx-data { max-width: 960px; } #minutely-precip-container { max-width: 1100px; } /* Loading spinner */ .forecast-loading { display: flex; /* Initially visible */ justify-content: center; align-items: center; height: 200px; margin: 20px 0; } .spinner { width: 50px; height: 50px; border: 5px solid rgba(0, 0, 0, 0.1); border-radius: 50%; border-top-color: var(--tesla-blue); animation: spin 1s linear infinite; } body.dark-mode .spinner { border-color: rgba(255, 255, 255, 0.1); border-top-color: var(--tesla-blue); } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* Weather condition-specific gradients */ .forecast-day.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .forecast-day.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .forecast-day.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .forecast-day.storm, .forecast-day.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .forecast-day.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } /* Invert snow icon in dark mode */ body.dark-mode .forecast-day.snow img.forecast-icon { filter: invert(1); } /* Invert clear icon in dark mode and make it grayscale */ body.dark-mode .forecast-day.clear img.forecast-icon { filter: invert(1); filter: grayscale(1); } /* Invert rain icon in dark mode and make it grayscale */ body.dark-mode .forecast-day.rain img.forecast-icon { filter: invert(1); filter: grayscale(1); } .forecast-alert { position: absolute; top: 5px; left: 7px; } .forecast-date { font-size: 13pt; font-weight: 600; margin-bottom: 8px; font-size: 12pt; font-weight: 650; text-transform: uppercase; color: var(--button-text); } .forecast-icon { width: 64px; height: 64px; margin: 9px; } .forecast-temp { font-size: 15pt; font-weight: 750; margin: 8px 0; color: var(--button-text); } .forecast-desc { font-family: "Inter", sans-serif; font-size: 14pt; font-style: italic; font-weight: 600; color: var(--button-text); } .forecast-popup { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: var(--active-section-bg); border-radius: var(--button-radius); padding: 20px; z-index: 1000; max-width: 90%; width: 90%; /* Increased width to fit more items */ max-height: 80vh; overflow-y: auto; box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.5); } /* Add a max-width constraint to match our 12-column layout */ @media (min-width: 1200px) { .forecast-popup { max-width: 1180px; /* 1140px (12-column width) + 40px (padding) */ width: 1180px; } } .forecast-popup.show { display: block; } .forecast-popup-close { position: absolute; right: 10px; top: 10px; background: rgb(205 205 205 / 50%); border: none; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 600; line-height: 1; /* font-family: monospace; */ padding-bottom: 4px; padding-left: 7px; color: var(--button-text); transition: all 0.2s ease; } .forecast-popup-close:hover { background: rgba(0, 0, 0, 0.2); color: #333; } .hourly-forecast { display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); /* Current setting */ gap: 6px; /* Reduced gap */ margin-top: 15px; max-width: 1140px; /* 90px × 12 + 6px × 11 gaps = 1146px, rounded down slightly */ margin-left: auto; margin-right: auto; } /* Add a media query for larger screens to explicitly limit to 12 columns */ @media (min-width: 1200px) { .hourly-forecast { grid-template-columns: repeat(12, 1fr); /* Force exactly 12 columns on wide screens */ } } .hourly-item { background: linear-gradient(to bottom, var(--sky-gradient-top), var(--sky-gradient-bottom)); padding: 6px; /* Reduced padding */ border-radius: var(--button-radius); text-align: center; transition: background 0.3s ease; } /* Weather condition-specific gradients for hourly items */ .hourly-item.clear { background: linear-gradient(to bottom, var(--sky-clear-top), var(--sky-clear-bottom)); } .hourly-item.clouds { background: linear-gradient(to bottom, var(--sky-cloudy-top), var(--sky-cloudy-bottom)); } .hourly-item.rain { background: linear-gradient(to bottom, var(--sky-rainy-top), var(--sky-rainy-bottom)); } .hourly-item.storm, .hourly-item.thunderstorm { background: linear-gradient(to bottom, var(--sky-storm-top), var(--sky-storm-bottom)); } .hourly-item.snow { background: linear-gradient(to bottom, var(--sky-snow-top), var(--sky-snow-bottom)); } .hourly-time { font-weight: 500; font-size: 11pt; /* Reduced font size */ color: var(--button-text); margin-bottom: 2px; /* Reduced margin */ } .hourly-icon { width: 32px; /* Smaller icon */ height: 32px; /* Smaller icon */ } .hourly-temp { color: var(--button-text); font-size: 11pt; /* Reduced font size */ font-weight: 750; margin: 2px 0; /* Reduced margin */ } .hourly-desc { color: var(--button-text); font-size: 10pt; /* Reduced font size */ font-style: italic; font-weight: 600; white-space: nowrap; /* Prevent text wrapping */ overflow: hidden; /* Hide text that doesn't fit */ text-overflow: ellipsis; /* Add ellipsis for overflow text */ } .station-name { font-size: 11pt; text-transform: uppercase; margin-left: 10px; color: var(--text-color); } /* Moon phase icon */ .moon-phase-icon { display: inline-block; width: 18px; height: 18px; margin-left: 8px; vertical-align: baseline; background-color: #ccc; border-radius: 50%; position: relative; overflow: hidden; } /* Dark mode styles for moon icon */ body.dark-mode .moon-phase-icon { background-color: #555; } /* Mobile Landscape Mode */ @media only screen and (max-width: 900px) and (orientation: landscape) { /* Weather switch elements */ .weather-switch-container { margin: 10px 0; } .weather-switch { width: 320px; } .weather-switch button { padding: 10px 16px; font-size: 14pt; } /* Weather images */ .weather-image { max-width: 95%; } /* Forecast container */ .forecast-container { gap: 10px; margin: 12px 0; max-width: 95%; } .forecast-day { padding: 10px; min-width: 100px; max-width: 140px; } .forecast-date { font-size: 10pt; margin-bottom: 5px; } .forecast-icon { width: 48px; height: 48px; margin: 5px; } .forecast-temp { font-size: 13pt; margin: 5px 0; } .forecast-desc { font-size: 12pt; } /* Forecast popup */ .forecast-popup { width: 90%; max-height: 70vh; } .hourly-forecast { grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); /* Even smaller for landscape */ gap: 5px; } .hourly-item { padding: 5px; } .hourly-time { font-size: 10pt; } .hourly-icon { width: 28px; height: 28px; } .hourly-temp { font-size: 10pt; margin: 1px 0; } .hourly-desc { font-size: 9pt; } /* Moon phase icon */ .moon-phase-icon { width: 14px; height: 14px; } /* Loading spinner */ .forecast-loading { height: 150px; } } /* Notification styles - with dark mode support */ .notification { display: flex; align-items: center; padding: 15px; margin-bottom: 10px; border-radius: 10px; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2); transform: translateX(120%); transition: transform 0.3s ease; opacity: 0.95; /* Light mode defaults */ background-color: rgba(255, 255, 255, 0.85); color: #cc6600; /* Darker orange for light mode */ } body.dark-mode .notification { background-color: rgba(33, 33, 33, 0.9); color: #ff7700; /* Brighter orange for dark mode */ } .notification.show { transform: translateX(0); } .notification.hide { transform: translateX(120%); } .notification-message { font-size: 18px; font-weight: 700; } /* Cloud icon styles for both status indicator and notification */ .rain-status img, .notification-icon img { filter: invert(50%) sepia(68%) saturate(3233%) hue-rotate(360deg) brightness(103%) contrast(103%); } body.dark-mode .rain-status img, body.dark-mode .notification-icon img { filter: invert(74%) sepia(69%) saturate(5422%) hue-rotate(359deg) brightness(101%) contrast(107%); } /* Add a little spacing between the notification icon and text */ .notification-icon { margin-right: 12px; } PKu*..PK-Zwx.jsSV// Import required functions from app.js import { formatTime, highlightUpdate, testMode } from './common.js'; import { settings, turnOffDarkMode, turnOnDarkMode } from './settings.js'; // Parameters const SAT_URLS = { latest: 'https://cdn.star.nesdis.noaa.gov/GOES19/ABI/CONUS/GEOCOLOR/1250x750.jpg', loop: 'https://cdn.star.nesdis.noaa.gov/GOES16/GLM/CONUS/EXTENT3/GOES16-CONUS-EXTENT3-625x375.gif', latest_ir: 'https://cdn.star.nesdis.noaa.gov/GOES16/ABI/CONUS/11/1250x750.jpg', }; // Module variables let forecastDataPrem = null; // has both current and forecast data let lastLat = null; let lastLong = null; let minutelyPrecipChart = null; let precipGraphUpdateInterval = null; // Timer for updating the precipitation graph let currentRainAlert = false; // Flag to track if we're currently under a rain alert // Export these variables for use in other modules export { SAT_URLS, forecastDataPrem }; // Automatically toggles dark mode based on sunrise and sunset times // TODO: This should really go in the settings module! export function autoDarkMode(lat, long) { // if lat or long are null, then replace with last known values if (lat == null || long == null) { if (lastLat && lastLong) { lat = lastLat; long = lastLong; } else { console.log('autoDarkMode: No coordinates available.'); return; } } console.log('Auto dark mode check for coordinates: ', lat, long); // Get sunrise and sunset times from forecastDataPrem const sunrise = forecastDataPrem?.current.sunrise * 1000; const sunset = forecastDataPrem?.current.sunset * 1000; if (!sunrise || !sunset) { console.log('Auto dark mode: No sunrise/sunset data available.'); return; } if (settings && settings['auto-dark-mode']) { const now = new Date(); const currentTime = now.getTime(); const sunriseTime = new Date(sunrise).getTime(); const sunsetTime = new Date(sunset).getTime(); if (currentTime >= sunsetTime || currentTime < sunriseTime) { console.log('Applying dark mode based on sunset...'); turnOnDarkMode(); } else { console.log('Applying light mode based on sunrise...'); turnOffDarkMode(); } } else { console.log('Auto dark mode disabled or coordinates not available.'); } } // Fetches premium weather data from OpenWeather API export function fetchPremiumWeatherData(lat, long, silentLoad = false) { console.log('Fetching premium weather data...'); // Save so we can call functions later outside GPS update loop, if needed lastLat = lat; lastLong = long; // Show loading spinner, hide forecast container - only if not silent loading const forecastContainer = document.getElementById('prem-forecast-container'); const loadingSpinner = document.getElementById('prem-forecast-loading'); // Remember display style of forecast container let lastDisplayStyle = forecastContainer.style.display; if (!silentLoad) { if (forecastContainer) forecastContainer.style.display = 'none'; if (loadingSpinner) loadingSpinner.style.display = 'flex'; } // Fetch and update weather data (single fetch) fetch(`openwx_proxy.php/data/3.0/onecall?lat=${lat}&lon=${long}&units=imperial`) .then(response => response.json()) .then(forecastDataLocal => { if (forecastDataLocal) { forecastDataPrem = forecastDataLocal; // If in test mode, generate random precipitation data for minutely forecast if (testMode) { console.log('TEST MODE: Generating random precipitation data'); // Create minutely data if it doesn't exist if (!forecastDataPrem.minutely || forecastDataPrem.minutely.length < 60) { forecastDataPrem.minutely = []; // Current timestamp in seconds, minus a random offset of 0-10 minutes const randomOffsetMinutes = Math.floor(Math.random() * 11); // 0-10 minutes const nowSec = Math.floor(Date.now() / 1000) - (randomOffsetMinutes * 60); console.log(`TEST MODE: Setting initial time to ${randomOffsetMinutes} minutes in the past`); // Generate 60 minutes of data for (let i = 0; i < 60; i++) { // First 18 data points have zero precipitation const precipitation = i < 18 ? 0 : Math.random() * 5; forecastDataPrem.minutely.push({ dt: nowSec + (i * 60), precipitation: precipitation }); } } else { // Modify existing minutely data const randomOffsetMinutes = Math.floor(Math.random() * 11); // 0-10 minutes const nowSec = Math.floor(Date.now() / 1000) - (randomOffsetMinutes * 60); console.log(`TEST MODE: Setting initial time to ${randomOffsetMinutes} minutes in the past`); forecastDataPrem.minutely.forEach((minute, index) => { minute.dt = nowSec + (index * 60); // First 18 data points have zero precipitation minute.precipitation = index < 18 ? 0 : Math.random() * 5; }); } // Make sure at least some values are non-zero to trigger display // Set a few minutes after the initial 18 to have definite precipitation for (let i = 25; i < 40; i++) { if (i < forecastDataPrem.minutely.length) { forecastDataPrem.minutely[i].precipitation = 2 + Math.random() * 3; // 2-5 mm/hr } } } updatePremiumWeatherDisplay(); // autoDarkMode(lat, long); // Update time and location of weather data, using FormatTime const weatherUpdateTime = formatTime(new Date(forecastDataLocal.current.dt * 1000), { hour: '2-digit', minute: '2-digit' }); // Get nearest city using OpenWeather GEOlocation API fetch(`openwx_proxy.php/geo/1.0/reverse?lat=${lat}&lon=${long}&limit=1`) .then(response => response.json()) .then(data => { if (data && data.length > 0) { const city = data[0].name; const state = data[0].state; const country = data[0].country; const stationStr = `${city}, ${state} @ ${weatherUpdateTime}`; highlightUpdate('prem-station-info', stationStr); } else { console.log('No location data available.'); } }) .catch(error => { console.error('Error fetching location data: ', error); }); // Start auto-refresh for precipitation graph startPrecipGraphAutoRefresh(); } else { console.log('No premium forecast data available.'); forecastDataPrem = null; } if (lat && long) { updateAQI(lat, long); } // Hide spinner and show forecast when data is loaded - only if not silent loading if (forecastContainer) forecastContainer.style.display = lastDisplayStyle; if (loadingSpinner) loadingSpinner.style.display = 'none'; }) .catch(error => { console.error('Error fetching forecast data: ', error); // In case of error, hide spinner and show error message - only if not silent loading if (!silentLoad) { if (loadingSpinner) loadingSpinner.style.display = 'none'; } }); } // Updates the forecast display with premium data export function updatePremiumWeatherDisplay() { if (!forecastDataPrem) return; // Extract daily summary (first 5 days) const dailyData = extractPremiumDailyForecast(forecastDataPrem.daily || []); const forecastDays = document.querySelectorAll('#prem-forecast-container .forecast-day'); dailyData.forEach((day, index) => { if (index < forecastDays.length) { const date = new Date(day.dt * 1000); const dayElement = forecastDays[index]; const hourlyAvail = index < 2 ? true : false; // Update weather condition class const hourlyClass = hourlyAvail ? 'hourly-avail' : null; const weatherCondition = day.weather[0].main.toLowerCase(); dayElement.className = `forecast-day ${hourlyClass} ${weatherCondition}`; // Update date const dateElement = dayElement.querySelector('.forecast-date'); if (dateElement) { dateElement.textContent = date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } // Update weather icon const iconElement = dayElement.querySelector('.forecast-icon'); if (iconElement) { iconElement.src = `https://openweathermap.org/img/wn/${day.weather[0].icon}@2x.png`; iconElement.alt = day.weather[0].description; } // Update temperature const tempElement = dayElement.querySelector('.forecast-temp'); if (tempElement) { tempElement.textContent = `${formatTemperature(day.temp.min)}/${formatTemperature(day.temp.max)}`; } // Update description const descElement = dayElement.querySelector('.forecast-desc'); if (descElement) { descElement.textContent = day.weather[0].main; } // Show or hide hazard alert const alertIcon = dayElement.querySelector('.forecast-alert'); if (premiumDayHasHazards(day)) { alertIcon.classList.remove('hidden'); } else { alertIcon.classList.add('hidden'); } // Attach click handler for precipitation graph? if (hourlyAvail) { dayElement.onclick = () => showPremiumPrecipGraph(index); } } }); // Update current conditions (from forecastDataPrem.current) if (forecastDataPrem.current) { const humidity = forecastDataPrem.current.humidity; const windSpeed = forecastDataPrem.current.wind_speed; const windGust = forecastDataPrem.current.wind_gust; const windDir = forecastDataPrem.current.wind_deg; highlightUpdate('prem-humidity', `${humidity}%`); if (windSpeed && windDir !== undefined) { let windText; if (windGust && windGust > windSpeed) { // Show wind-gust format windText = `${formatWindSpeedRange(windSpeed, windGust)} at ${Math.round(windDir)}°`; } else { // Just show regular wind speed if no gust data windText = `${formatWindSpeed(windSpeed)} at ${Math.round(windDir)}°`; } highlightUpdate('prem-wind', windText); } else { highlightUpdate('prem-wind', '--'); } } // Update solar and moon data (from forecastDataPrem.daily[0]) if (forecastDataPrem.daily && forecastDataPrem.daily[0]) { const today = forecastDataPrem.daily[0]; const sunriseTime = formatTime(new Date(today.sunrise * 1000), { timeZoneName: 'short' }); highlightUpdate('prem-sunrise', sunriseTime); const sunsetTime = formatTime(new Date(today.sunset * 1000), { timeZoneName: 'short' }); highlightUpdate('prem-sunset', sunsetTime); if (today.moon_phase !== undefined) { const moonPhase = getMoonPhaseName(today.moon_phase); highlightUpdate('prem-moonphase', moonPhase); // Update the moon icon const moonIcon = document.getElementById('prem-moon-icon'); if (moonIcon) { moonIcon.setAttribute('style', getMoonPhaseIcon(today.moon_phase)); } } } // Update precipitation graph with time-based x-axis updatePrecipitationGraph(); // Hide spinner, show forecast const forecastContainer = document.getElementById('prem-forecast-container'); const loadingSpinner = document.getElementById('prem-forecast-loading'); if (forecastContainer) forecastContainer.classList.remove('hidden'); if (loadingSpinner) loadingSpinner.style.display = 'none'; } // Function to update precipitation graph with current time-based x-axis function updatePrecipitationGraph() { if (!forecastDataPrem || !forecastDataPrem.minutely) return; const minutely = forecastDataPrem.minutely || []; let hasMinutelyPrecip = false; if (minutely.length > 0) { const currentTime = new Date(); console.log(`Updating precipitation graph at: ${currentTime.toLocaleTimeString()}`); // Calculate time offsets relative to now and filter out past times const precipData = minutely.map(m => { const minuteTime = new Date(m.dt * 1000); const timeDiffMinutes = Math.round((minuteTime - currentTime) / (60 * 1000)); return { x: timeDiffMinutes, y: m.precipitation || 0, time: minuteTime }; }).filter(item => item.x >= 0); // Filter out past times (negative values) // Extract data for chart const labels = precipData.map(item => item.x); const values = precipData.map(item => item.y); // Check if any precipitation values are greater than 0 hasMinutelyPrecip = values.some(val => val > 0); // Check for rain in the next 15 minutes and show alert if detected checkImminentRain(minutely); const minutelyContainer = document.getElementById('minutely-precip-container'); const minutelyChartCanvas = document.getElementById('minutely-precip-chart'); if (hasMinutelyPrecip && minutelyContainer && minutelyChartCanvas) { minutelyContainer.style.display = ''; // Draw or update the chart if (minutelyPrecipChart) { minutelyPrecipChart.destroy(); } minutelyPrecipChart = new Chart(minutelyChartCanvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: [{ label: 'Precipitation (mm/hr)', data: values, backgroundColor: 'rgba(255, 119, 0, 0.6)' }] }, options: { plugins: { legend: { display: false } }, scales: { x: { title: { display: true, text: 'Minutes from now', font: { size: 22, weight: 650 } }, ticks: { font: { size: 18 }, callback: function(value) { // Format as "+" for future minutes return "+" + value; } } }, y: { title: { display: true, text: 'Precipitation (mm/hr)', font: { size: 22, weight: 650 } }, beginAtZero: true, ticks: { font: { size: 18 } } } }, animation: { duration: 200 // Fast animation for updates } } }); } else { // Hide the graph if no precipitation data if (minutelyContainer) minutelyContainer.style.display = 'none'; if (minutelyPrecipChart) { minutelyPrecipChart.destroy(); minutelyPrecipChart = null; } // Don't stop the timer here - we should keep checking for precipitation // Just log that there's no data currently console.log('No precipitation data to display, but continuing to monitor'); } } else { // If no minutely data available, hide the rain indicator toggleRainIndicator(false); // Don't stop the refresh here - data might become available later console.log('No minutely precipitation data available, continuing to monitor'); } // Return true if the refresh should continue return true; } // Function to start auto-refresh for precipitation graph function startPrecipGraphAutoRefresh() { // Clear any existing interval first clearInterval(precipGraphUpdateInterval); console.log('Starting precipitation graph auto-refresh'); // Initial update // updatePrecipitationGraph(); // Set up interval to update every 30 seconds precipGraphUpdateInterval = setInterval(() => { // Log refresh state console.log('Running precipitation graph refresh check...'); updatePrecipitationGraph(); }, 30000); // Update every 30 seconds } // New function: Check for imminent rain (next 15 minutes) function checkImminentRain(minutelyData) { if (!minutelyData || minutelyData.length === 0) { toggleRainIndicator(false); currentRainAlert = false; // Reset alert flag when no data return false; } // Get current time const currentTime = new Date(); // Filter and process only the next 15 minutes of data const next15MinData = minutelyData.filter(minute => { const minuteTime = new Date(minute.dt * 1000); const timeDiffMinutes = (minuteTime - currentTime) / (60 * 1000); // Include only future times within the next 15 minutes return timeDiffMinutes >= 0 && timeDiffMinutes <= 15; }); // Determine if any precipitation is expected in the next 15 minutes // Using a small threshold to filter out trace amounts const precipThreshold = 0.1; // mm/hr const hasImminentRain = next15MinData.some(minute => (minute.precipitation || 0) > precipThreshold ); // Toggle the rain indicator based on our findings toggleRainIndicator(hasImminentRain); // If rain is imminent and we don't have an active alert already, show a notification if (hasImminentRain && !currentRainAlert) { // Calculate when rain will start (first minute above threshold) const rainStartIndex = next15MinData.findIndex(minute => (minute.precipitation || 0) > precipThreshold ); // Find the maximum precipitation intensity in the next 15 minutes const maxPrecip = Math.max(...next15MinData.map(minute => minute.precipitation || 0)); // Create the notification message let message; if (rainStartIndex === 0) { message = `Rain detected now! (${maxPrecip.toFixed(1)} mm/hr)`; } else if (rainStartIndex > 0) { const minuteTime = new Date(next15MinData[rainStartIndex].dt * 1000); const minutesUntilRain = Math.round((minuteTime - currentTime) / (60 * 1000)); message = `Rain expected in ${minutesUntilRain} minute${minutesUntilRain > 1 ? 's' : ''} (${maxPrecip.toFixed(1)} mm/hr)`; } if (message) { // Show the notification showNotification(message); // Set flag that we're under an active rain alert currentRainAlert = true; } } else if (!hasImminentRain) { // Reset the alert flag when there's no longer imminent rain currentRainAlert = false; } return hasImminentRain; } // New function: Toggle the rain indicator function toggleRainIndicator(show) { // Get or create the rain indicator element let rainIndicator = document.getElementById('rain-indicator'); if (!rainIndicator && show) { // Create the rain indicator if it doesn't exist rainIndicator = document.createElement('div'); rainIndicator.id = 'rain-indicator'; rainIndicator.className = 'status-indicator rain-status'; rainIndicator.title = 'Rain expected within 15 minutes'; // Add img element for cloud icon using the external SVG file rainIndicator.innerHTML = `Rain Alert`; // Insert at the beginning of the control container and center it horizontally const controlContainer = document.querySelector('.control-container'); if (controlContainer) { controlContainer.insertBefore(rainIndicator, controlContainer.firstChild); } } else if (rainIndicator && !show) { // Remove the indicator if it exists and should not be shown rainIndicator.remove(); } } // New function: Show a temporary notification function showNotification(message) { // Check if a notification container already exists let notificationContainer = document.getElementById('notification-container'); if (!notificationContainer) { // Create a notification container if it doesn't exist notificationContainer = document.createElement('div'); notificationContainer.id = 'notification-container'; document.body.appendChild(notificationContainer); } // Create the notification element const notification = document.createElement('div'); notification.className = 'notification'; notification.innerHTML = `
Rain Alert
${message}
`; // Add the notification to the container notificationContainer.appendChild(notification); // Make the notification visible with a fade-in effect setTimeout(() => { notification.classList.add('show'); }, 10); // Remove the notification after 5 seconds setTimeout(() => { notification.classList.remove('show'); notification.classList.add('hide'); // Remove the element after the fade-out animation completes setTimeout(() => { notification.remove(); // Remove the container if there are no more notifications if (notificationContainer.children.length === 0) { notificationContainer.remove(); } }, 300); }, 5000); } // Helper: Extract 5 daily summaries from OpenWeather 3.0 API function extractPremiumDailyForecast(dailyList) { // dailyList is already daily summaries (up to 8 days) return dailyList.slice(0, 5); } // Consolidated: Format temperature based on user settings function formatTemperature(tempF) { if (!settings || settings["imperial-units"]) { return Math.round(tempF) + "°"; } else { // Convert F to C: (F - 32) * 5/9 return Math.round((tempF - 32) * 5/9) + "°"; } } // Consolidated: Format wind speed based on user settings function formatWindSpeed(speedMS) { if (!settings || settings["imperial-units"]) { // Convert m/s to mph return Math.round(speedMS * 2.237) + " MPH"; } else { // Keep as m/s return Math.round(speedMS) + " m/s"; } } // Add this new helper function before the end of the file, near the other formatting functions function formatWindSpeedRange(speedMS, gustMS) { if (!settings || settings["imperial-units"]) { // Convert m/s to mph return `${Math.round(speedMS * 2.237)}-${Math.round(gustMS * 2.237)} MPH`; } else { // Keep as m/s return `${Math.round(speedMS)}-${Math.round(gustMS)} m/s`; } } // Helper: Check for hazards in a premium daily forecast function premiumDayHasHazards(day) { const hazardConditions = ['Rain', 'Snow', 'Sleet', 'Hail', 'Thunderstorm', 'Storm', 'Drizzle']; return day.weather.some(w => hazardConditions.some(condition => w.main.includes(condition) || w.description.toLowerCase().includes(condition.toLowerCase()) ) ); } // Generate CSS styling for the moon phase icon based on phase value function getMoonPhaseIcon(phase) { // Create CSS for the moon icon based on the phase value (0 to 1) // 0 = new moon (fully dark), 0.5 = full moon (fully light), 1 = new moon again let style = ''; if (phase === 0 || phase === 1) { // New moon - completely dark circle style = 'background-color: #000;'; } else if (phase === 0.5) { // Full moon - completely light circle style = 'background-color: #fff; box-shadow: inset 0 0 4px rgba(0,0,0,0.2);'; } else if (phase < 0.5) { // Waxing moon - illuminated from right const percentageVisible = phase * 2; // 0 to 1 style = `background-color: #000; box-shadow: inset ${12 * percentageVisible}px 0 0 0 #fff;`; } else { // Waning moon - illuminated from left const percentageVisible = (1 - phase) * 2; // 1 to 0 style = `background-color: #000; box-shadow: inset -${12 * percentageVisible}px 0 0 0 #fff;`; } return style; } // Return string description of the closest moon phase function getMoonPhaseName(phase) { if (phase < 0.05) { return 'New'; } else if (phase < 0.35) { return 'Crescent'; } else if (phase < 0.65) { return 'Quarter'; } else if (phase < 0.95) { return 'Gibbous'; } else { return 'Full Moon'; } } // Fetches and updates the Air Quality Index (AQI) from openweather.org function updateAQI(lat, lon) { fetch(`openwx_proxy.php/data/2.5/air_pollution?lat=${lat}&lon=${lon}`) .then(response => response.json()) .then(data => { const aqi = data.list[0].main.aqi; let aqiText = ''; let color = ''; switch (aqi) { case 1: aqiText = 'Good'; color = 'green'; break; case 2: aqiText = 'Fine'; color = 'lightgreen'; break; case 3: aqiText = 'Moderate'; color = 'orange'; break; case 4: aqiText = 'Poor'; color = 'orangered'; break; case 5: aqiText = 'Very Poor'; color = 'red'; break; default: aqiText = 'Unknown'; color = 'gray'; } highlightUpdate('prem-aqi', aqiText); document.getElementById('prem-aqi-dot').style.backgroundColor = color; }); } // Show precipitation graph for a premium forecast day window.showPremiumPrecipGraph = function(dayIndex) { if (!forecastDataPrem) return; const daily = forecastDataPrem.daily || []; const hourly = forecastDataPrem.hourly || []; if (!daily[dayIndex]) return; // Show only the premium popup const premPopup = document.querySelector('#prem-weather .forecast-popup'); if (premPopup) { premPopup.classList.add('show'); } // Calculate start/end of the selected day in local time const selectedDate = new Date(daily[dayIndex].dt * 1000); const dayStart = new Date(selectedDate); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(selectedDate); dayEnd.setHours(23, 59, 59, 999); // Set popup title with local date const popupDate = premPopup.querySelector('#popup-date'); if (popupDate) { popupDate.textContent = selectedDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); } const hourlyContainer = premPopup.querySelector('.hourly-forecast'); // Simple rule: Only show hourly forecasts for the first two days (index 0 and 1) if (dayIndex > 1) { // Beyond day 2 - show simplified message hourlyContainer.innerHTML = `

Detailed hourly forecast is only available for the next 2 days.

`; return; } // Check if we're beyond the hourly forecast limit (48 hours) - keeping this check as a fallback const now = new Date(); const hoursDiff = (dayStart - now) / (1000 * 60 * 60); if (hoursDiff >= 48) { // Beyond hourly forecast limit - show simplified message hourlyContainer.innerHTML = `

Detailed hourly forecast is only available for the next 48 hours.

`; return; } // Filter hourly data for the selected day using local time comparison const dayHourly = hourly.filter(h => { const itemDate = new Date(h.dt * 1000); return itemDate >= dayStart && itemDate <= dayEnd; }); // Create a new timeline-based hourly forecast view if (hourlyContainer) { // Make sure we have at least one hour of data if (dayHourly.length === 0) { hourlyContainer.innerHTML = `

No hourly data available for this day.

`; return; } // First, set up the container styles for the new timeline layout hourlyContainer.style.display = 'flex'; hourlyContainer.style.flexDirection = 'column'; hourlyContainer.style.width = '100%'; hourlyContainer.style.padding = '0'; // Remove padding to align date heading with timeline // Create a timeline view with continuous weather rectangles let timelineHTML = `
`; // Create weather condition rectangles and detect weather changes for icon placement let prevWeatherMain = null; let weatherChangePoints = []; let weatherChangeIcons = []; // Calculate total width of each hour segment const hourWidth = 100 / dayHourly.length; // percentage width // Generate the weather rectangles dayHourly.forEach((item, index) => { const weatherCondition = item.weather[0].main.toLowerCase(); const itemDate = new Date(item.dt * 1000); const hour = itemDate.getHours(); // Check for weather condition changes to place icons if (prevWeatherMain !== item.weather[0].main) { weatherChangePoints.push(index); weatherChangeIcons.push({ position: index, icon: item.weather[0].icon, description: item.weather[0].description }); prevWeatherMain = item.weather[0].main; } // Add the weather condition rectangle timelineHTML += `
`; }); timelineHTML += `
`; // Add weather change icons timelineHTML += `
`; weatherChangeIcons.forEach(change => { // Position the icon exactly at the boundary between hours const iconLeft = change.position * hourWidth; timelineHTML += `
${change.description}
`; }); timelineHTML += `
`; // Add temperature indicators (every 3 hours) timelineHTML += `
`; dayHourly.forEach((item, index) => { const itemDate = new Date(item.dt * 1000); const hour = itemDate.getHours(); // Only show temperature every 3 hours if (hour % 3 === 0 || index === 0) { // Center the temperature in each rectangle const tempLeft = (index * hourWidth) + (hourWidth / 2); timelineHTML += `
${formatTemperature(item.temp)}
`; } }); timelineHTML += `
`; // Add hour labels at the bottom (every 3 hours) timelineHTML += `
`; dayHourly.forEach((item, index) => { const itemDate = new Date(item.dt * 1000); const hour = itemDate.getHours(); // Only show labels every 3 hours if (hour % 3 === 0 || index === 0) { // Position labels to align with the center of their corresponding rectangle const labelLeft = (index * hourWidth) + (hourWidth / 2); const time = formatTime(itemDate, { hour: 'numeric', minute: '2-digit' }); timelineHTML += `
${time}
`; } }); timelineHTML += `
`; timelineHTML += `
`; // Add a legend for weather conditions timelineHTML += `
`; // Get unique weather conditions for legend const uniqueConditions = new Set(); dayHourly.forEach(item => uniqueConditions.add(item.weather[0].main)); uniqueConditions.forEach(condition => { const conditionClass = condition.toLowerCase(); timelineHTML += `
${condition}
`; }); timelineHTML += `
`; hourlyContainer.innerHTML = timelineHTML; } }; // Premium popup close handler window.closePremiumPrecipPopup = function() { const premPopup = document.querySelector('#prem-weather .forecast-popup'); if (premPopup) premPopup.classList.remove('show'); } // Switches the weather image based on the type provided window.switchWeatherImage = function (type) { const weatherImage = document.getElementById('weather-image'); weatherImage.style.opacity = '0'; setTimeout(() => { weatherImage.src = SAT_URLS[type]; weatherImage.style.opacity = '1'; }, 300); // Update buttons and slider position const weatherSwitch = document.querySelector('.weather-switch'); const buttons = weatherSwitch.getElementsByTagName('button'); buttons[0].classList.toggle('active', type === 'latest'); buttons[1].classList.toggle('active', type === 'loop'); buttons[2].classList.toggle('active', type === 'latest_ir'); // Update slider position for three states const positions = { 'latest': 0, 'loop': 1, 'latest_ir': 2 }; weatherSwitch.style.setProperty('--slider-position', positions[type]); } PK-tvnnPK-G.Z=gMM LICENSEPK- /Z NOTEPK-%Z` ߮ |README.mdPK-G.Zi[ SECURITY.mdPK-jZW/ww app.jsPK-ZPP cloud.svgPK-$Z,M mcommon.jsPK-G.Z= favicon.svgPK-G.ZfQ git_info.phpPK-G.Z*mc/ icons.svgPK-jZ#? +2index.htmlPK-G.Zw,σ xlocation.jsPK-$Z$iC1C1 Tnet.jsPK-$Z4A00 *news.jsPK-G.ZNPP [openwx_proxy.phpPK-G.ZV- cping.phpPK-%Z馬9/4/4 evrss.phpPK-Zc:9X9X settings.jsPK-G.Z#V#V {settings.phpPK-m.Z6HH Yshare.phpPK-m.Z0 ashare.svgPK-$Z7)ss cstyles.cssPK-$Zzs timeline.cssPK-G.ZY#   vers.phpPK-G.Zjj warn.svgPK-Zu*.. wx.cssPK-Z-tvnn wx.jsPKt