diff --git a/iridescent-ivies/.github/workflows/lint.yaml b/iridescent-ivies/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/iridescent-ivies/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/iridescent-ivies/.gitignore b/iridescent-ivies/.gitignore new file mode 100644 index 00000000..08ef4ac4 --- /dev/null +++ b/iridescent-ivies/.gitignore @@ -0,0 +1,32 @@ +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ +uv.lock +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +# MacOS +.DS_Store +src/sql_bsky.egg-info/* diff --git a/iridescent-ivies/.pre-commit-config.yaml b/iridescent-ivies/.pre-commit-config.yaml new file mode 100644 index 00000000..de7f5fd9 --- /dev/null +++ b/iridescent-ivies/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-check + - id: ruff-format + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.8.8 + hooks: + - id: pip-compile + args: [ + "--universal", + "--python-version=3.12", + "pyproject.toml", + "--group=dev", + "-o", + "requirements.txt" + ] diff --git a/iridescent-ivies/LICENSE.txt b/iridescent-ivies/LICENSE.txt new file mode 100644 index 00000000..2f024be1 --- /dev/null +++ b/iridescent-ivies/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Iridescent Ivies + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/iridescent-ivies/README.md b/iridescent-ivies/README.md new file mode 100644 index 00000000..97fa38d2 --- /dev/null +++ b/iridescent-ivies/README.md @@ -0,0 +1,131 @@ +## The Social Query Language (SQL-BSky) + +[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://python.org) +[![BlueSky](https://img.shields.io/badge/BlueSky-AT_Protocol-00D4FF.svg)](https://bsky.app) +[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE.txt) +[![Status](https://img.shields.io/badge/Status-Active-brightgreen.svg)]() + +A retro terminal-style SQL interface for querying the BlueSky social network. Experience social media through the lens of structured query language with authentic CRT visual effects. + +![App Initialization](assets/Init_sql_app.gif) + +## Features + +- **Dual Authentication**: Full BlueSky login or anonymous "stealth mode" +- **Public API Access**: Query public content without authentication +- **ASCII Art Images**: View embedded images as beautiful ASCII art +- **Real-time Validation**: Live SQL syntax checking as you type +- **Retro CRT Interface**: Authentic 1980s terminal experience with visual effects +- **Fast Performance**: Optimized queries with scrolling support +- **Easter Eggs**: Hidden surprises for the adventurous + +## Quick Start + +### Installation + +1. Clone the repository: + ```bash + git clone git@github.com:A5rocks/code-jam-12.git + + # move to the dir + cd code-jam-12 + ``` +2. Start the development server: + ```bash + python3 dev.py + ``` + +3. That's it! Open your browser to: [http://localhost:8000](http://localhost:8000) + +### First Steps + +1. **Choose Authentication Mode**: + - **Authenticated**: Login with BlueSky credentials for full access + - **Stealth Mode**: Browse public content anonymously + +> [!NOTE] +> If the page is slow, try disabling the CRT effect at this point. + +2. **Try Your First Query**: + ```sql + SELECT * FROM tables + ``` + + ![Running Test Query](assets/run_test_query.gif) + +3. **Explore Public Profiles**: + ```sql + SELECT * FROM profile WHERE actor = 'bsky.app' + ``` + +## Query Reference + +### Available Tables + +| Table | Description | Auth Required | Parameters | +|-------|-------------|---------------|------------| +| `tables` | List all available tables | No | None | +| `profile` | User profile information | No | `actor` (optional) | +| `feed` | Posts from a specific user | No | `author` (required) | +| `timeline` | Your personal timeline | Yes | None | +| `suggestions` | Suggested users to follow | No | None | +| `suggested_feed` | Recommended feeds | No | None | +| `followers` | User's followers | No | `actor` (required) | +| `following` | Who user follows | No | `actor` (required) | +| `mutuals` | Mutual connections | No | `actor` (required) | +| `likes` | User's liked posts | Yes | `actor` (required) | + +### Example Queries + +```sql +SELECT * FROM feed WHERE author='bsky.app' +``` +- This will get all fields from all posts from the author's feed + +```sql +SELECT description FROM followers WHERE author='bsky.app' +``` +- This will get the bio of all followers of the author + +```sql +SELECT * FROM tables +``` +- This will get all available table names + +## Known Issues + +> [!WARNING] +> Please be aware of these current limitations before using the application. + +> [!NOTE] +> Queries to non-existent tables or fields will return empty rows instead of proper error messages. + +**Example:** +```sql +-- Both of these return empty rows (same behavior) +SELECT likes FROM feed WHERE author = "bsky.app" +SELECT apples FROM feed WHERE author = "bsky.app" +``` + +### KeyError in Feed Processing +> [!IMPORTANT] +> There's a known KeyError where the system looks for `"feeds"` but should be looking for `"feed"`. This is a human error we discovered after the Code Jam programming time had ended, so we weren't able to fix it, but we're aware of the issue and it may cause some like-table-related queries to fail unexpectedly. + +##### Table `likes` Not Functional +> [!CAUTION] +> The `likes` table is currently broken and behaves like a non-existent table. This is due to the KeyError +## Team - Iridescent Ivies + +- **A5rocks** - [GitHub](https://github.com/A5rocks) (Team Leader) +- **TheHeretic** - [GitHub](https://github.com/DannyTheHeretic) +- **Walkercito** - [GitHub](https://github.com/Walkercito) +- **Joshdtbx** - [GitHub](https://github.com/giplgwm) +- **Mimic** - [GitHub](https://github.com/Drakariboo) + +## License + +This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details. + +--- + +**Thank you for exploring our project!!** diff --git a/iridescent-ivies/assets/Init_sql_app.gif b/iridescent-ivies/assets/Init_sql_app.gif new file mode 100644 index 00000000..f0c6634d Binary files /dev/null and b/iridescent-ivies/assets/Init_sql_app.gif differ diff --git a/iridescent-ivies/assets/run_test_query.gif b/iridescent-ivies/assets/run_test_query.gif new file mode 100644 index 00000000..a1adab67 Binary files /dev/null and b/iridescent-ivies/assets/run_test_query.gif differ diff --git a/iridescent-ivies/dev.py b/iridescent-ivies/dev.py new file mode 100644 index 00000000..95f5e3e4 --- /dev/null +++ b/iridescent-ivies/dev.py @@ -0,0 +1,29 @@ +import http.server +import os +import socketserver +import sys +from pathlib import Path + +# use src as start point +src_dir = Path(__file__).parent / "src" +if src_dir.exists(): + os.chdir(src_dir) + print(f"[*] Serving from: {src_dir.absolute()}") +else: + print("[-] src/ dir not found") + sys.exit(1) + +PORT = 8000 +Handler = http.server.SimpleHTTPRequestHandler + +try: + with socketserver.TCPServer(("", PORT), Handler) as httpd: + print(f"[*] Server running at: http://localhost:{PORT}") + print(f"[*] Open: http://localhost:{PORT}/") + print("[-] Press Ctrl+C to stop") + httpd.serve_forever() +except KeyboardInterrupt: + print("\nServer stopped") +except OSError as e: + print(f"[-] Error: {e}") + print("[-] Try a different port: python dev.py --port 8001") diff --git a/iridescent-ivies/pyproject.toml b/iridescent-ivies/pyproject.toml new file mode 100644 index 00000000..f4c1c29c --- /dev/null +++ b/iridescent-ivies/pyproject.toml @@ -0,0 +1,54 @@ +[project] +name = "sql-bsky" +description = "Social query language" +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "ascii-magic>=2.3.0", +] + +[dependency-groups] +dev = [ + "pre-commit~=4.2.0", + "ruff~=0.12.2", + "pytest", +] + +[tool.ruff] +line-length = 119 +target-version = "py312" +fix = true +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Conflicts with ruff format. + "COM812", + # Asserts are good, actually. + "S101", +] diff --git a/iridescent-ivies/requirements.txt b/iridescent-ivies/requirements.txt new file mode 100644 index 00000000..3721e7c9 --- /dev/null +++ b/iridescent-ivies/requirements.txt @@ -0,0 +1,40 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --python-version=3.12 pyproject.toml --group=dev -o requirements.txt +ascii-magic==2.3.0 + # via sql-bsky (pyproject.toml) +cfgv==3.4.0 + # via pre-commit +colorama==0.4.6 + # via + # ascii-magic + # pytest +distlib==0.4.0 + # via virtualenv +filelock==3.18.0 + # via virtualenv +identify==2.6.12 + # via pre-commit +iniconfig==2.1.0 + # via pytest +nodeenv==1.9.1 + # via pre-commit +packaging==25.0 + # via pytest +pillow==11.3.0 + # via ascii-magic +platformdirs==4.3.8 + # via virtualenv +pluggy==1.6.0 + # via pytest +pre-commit==4.2.0 + # via sql-bsky (pyproject.toml:dev) +pygments==2.19.2 + # via pytest +pytest==8.4.1 + # via sql-bsky (pyproject.toml:dev) +pyyaml==6.0.2 + # via pre-commit +ruff==0.12.8 + # via sql-bsky (pyproject.toml:dev) +virtualenv==20.33.1 + # via pre-commit diff --git a/iridescent-ivies/src/__init__.py b/iridescent-ivies/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/iridescent-ivies/src/api/__init__.py b/iridescent-ivies/src/api/__init__.py new file mode 100644 index 00000000..4b9a314e --- /dev/null +++ b/iridescent-ivies/src/api/__init__.py @@ -0,0 +1 @@ +# The main portion of fetching and recieving from Bsky diff --git a/iridescent-ivies/src/api/auth_session.py b/iridescent-ivies/src/api/auth_session.py new file mode 100644 index 00000000..e5f6d9e5 --- /dev/null +++ b/iridescent-ivies/src/api/auth_session.py @@ -0,0 +1,276 @@ +# Imports +import json +from typing import Literal + +from pyodide.http import FetchResponse, pyfetch # The system we will actually use + +LIMIT = 50 # The default limit amount + + +class PyfetchSession: + """Pyfetch Session, emulating the request Session.""" + + def __init__(self, headers: dict | None = None) -> None: + """Pyfetch Session, emulating the request Session.""" + self.default_headers = headers or {} + + async def get(self, url: str, headers: dict | None = None) -> FetchResponse: + """Get request for the pyfetch. + + Args: + url (str): The Endpoint to hit + headers (dict | None, optional): Any headers that will get added to the request. Defaults to "". + + Returns: + FetchResponse: The return data from the request + + """ + merged_headers = self.default_headers.copy() + if headers: + merged_headers.update(headers) + return await pyfetch( + url, + method="GET", + headers=merged_headers, + ) + + async def post( + self, + url: str, + data: str | dict | None = "", + headers: dict | None = None, + ) -> FetchResponse: + """Post request. + + Args: + url (str): The Endpoint to hit + data (str | dict | None, optional): A dictionary or string to use for the body. Defaults to "". + headers (dict | None, optional): Any headers that will get added to the request. Defaults to "". + + Returns: + FetchResponse: The return data from the request + + """ + merged_headers = self.default_headers.copy() + if headers: + merged_headers.update(headers) + return await pyfetch( + url, + method="POST", + headers=merged_headers, + body=json.dumps(data) if isinstance(data, dict) else data, + ) + + +class BskySession: + """Class to establish an auth session.""" + + def __init__(self, username: str, password: str) -> None: + # Bluesky credentials + self.username = username + self.password = password + self.pds_host = "https://public.api.bsky.app" + # Instance client + self.client = PyfetchSession() + # Access token + self.access_jwt = None + # Refresh token + self.refresh_jwt = None + + async def login(self) -> None: + """Create an authenticated session and save tokens.""" + endpoint: str = "https://bsky.social/xrpc/com.atproto.server.createSession" + session_info: FetchResponse = await self.client.post( + endpoint, + headers={"Content-Type": "application/json"}, + data={ + "identifier": self.username, + "password": self.password, + }, + ) + session_info: dict = await session_info.json() + try: + self.access_jwt: str = session_info["accessJwt"] + self.refresh_jwt: str = session_info["refreshJwt"] + self.did: str = session_info["did"] + self.handle: str = session_info["handle"] + self.pds_host = "https://bsky.social" + self.client.default_headers.update( + { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_jwt}", + }, + ) + except KeyError: + # TODO: Handle the error on the front end + return False + else: + return True + + async def refresh_token(self) -> None: + """Refresh the token.""" + endpoint = f"{self.pds_host}/xrpc/com.atproto.server.refreshSession" + + session_info = await self.client.post( + endpoint, data="", headers={"Authorization": f"Bearer {self.refresh_jwt}"} + ) + session_info = await session_info.json() + self.access_jwt = session_info["accessJwt"] + self.refresh_jwt = session_info["refreshJwt"] + self.did = session_info["did"] + + self.client.default_headers.update( + { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_jwt}", + }, + ) + + ### Start of the actual endpoints -> https://docs.bsky.app/docs/api/at-protocol-xrpc-api + async def get_preferences(self) -> dict: + """Get the logged in users preferences.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.actor.getPreferences" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_profile(self, actor: str) -> dict: + """Get a user profile.""" + # If no actor specified and we're authenticated, use our handle + if actor is None: + if hasattr(self, "handle") and self.handle: + actor = self.handle + else: + # Return special error object for stealth mode + return {"stealth_error": True} + + endpoint = f"{self.pds_host}/xrpc/app.bsky.actor.getProfile?actor={actor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_suggestions(self, limit: int = LIMIT, cursor: str = "") -> dict: + """Get the logged in users suggestion.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.actor.getSuggestions?limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def search_actors(self, q: str, limit: int = LIMIT, cursor: str = "") -> dict: + """Search for actors.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.actor.searchActors?q={q}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_actor_likes(self, actor: str, limit: int = LIMIT, cursor: str = "") -> dict: # Requires Auth + """Get a given actors likes.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.feed.getActorLikes?actor={actor}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_author_feed(self, actor: str, limit: int = LIMIT) -> dict: + """Get a specific user feed.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.feed.getAuthorFeed?actor={actor}&limit={limit}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_feed(self, feed: str, limit: int = LIMIT, cursor: str = "") -> dict: + """Get a specified feed.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.feed.getFeed?feed={feed}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_suggested_feeds(self, limit: int = LIMIT, cursor: str = "") -> dict: + """Get suggested feeds.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.feed.getSuggestedFeeds?limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_timeline(self) -> dict: + """Get a users timeline.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.feed.getTimeline" + response = await self.client.get( + endpoint, + ) + return await response.json() + + # Only function that needs this many params, I am not making a data class for it + async def search_posts( # noqa: PLR0913 + self, + q: str, + limit: int = LIMIT, + sort: Literal["top", "latest"] = "latest", + since: str = "", + until: str = "", + mentions: str = "", + author: str = "", + tag: str = "", + cursor: str = "", + ) -> dict: + """Search for bluesky posts. + + Args: + q (str): the given query + sort (Literal["top", "latest"], optional): The sort Order. Defaults to "latest". + since (str, optional): Since when in YYYY-MM-DD format. Defaults to "". + until (str, optional): Until when in YYYY-MM-DD format. Defaults to "". + mentions (str, optional): Post mentions the given account. Defaults to "". + author (str, optional): Author of a given post. Defaults to "". + tag (str, optional): Tags on the post. Defaults to "". + limit (int, optional): Limit the number returned. Defaults to LIMIT. + cursor (str, optional): Bsky Cursor. Defaults to "". + + """ + endpoint = ( + f"{self.pds_host}/xrpc/app.bsky.feed.searchPosts" + f"?q={q}&sort={sort}&since={since}&until={until}" + f"&mentions={mentions}&author={author}&tag={tag}" + f"&limit={limit}&cursor={cursor}" + ) + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_followers(self, actor: str, limit: int = LIMIT, cursor: str = "") -> dict: + """Get a users followers.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.graph.getFollowers?actor={actor}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_follows(self, actor: str, limit: int = LIMIT, cursor: str = "") -> dict: + """Get a users follows.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.graph.getFollows?actor={actor}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_mutual_follows(self, actor: str, limit: int = LIMIT, cursor: str = "") -> dict: + """Get a users mutual follows.""" + endpoint = f"{self.pds_host}/xrpc/app.bsky.graph.getKnownFollowers?actor={actor}&limit={limit}&cursor={cursor}" + response = await self.client.get( + endpoint, + ) + return await response.json() + + async def get_blob(self, url: str) -> str: + """Get a specific blob.""" + did, cid = url.split("/")[-2:] + cid = cid.split("@")[0] + return f"https://bsky.social/xrpc/com.atproto.sync.getBlob?did={did}&cid={cid}" diff --git a/iridescent-ivies/src/core/__init__.py b/iridescent-ivies/src/core/__init__.py new file mode 100644 index 00000000..446c6f0e --- /dev/null +++ b/iridescent-ivies/src/core/__init__.py @@ -0,0 +1 @@ +# The core of the Social Query Language diff --git a/iridescent-ivies/src/core/functions.py b/iridescent-ivies/src/core/functions.py new file mode 100644 index 00000000..c4a31046 --- /dev/null +++ b/iridescent-ivies/src/core/functions.py @@ -0,0 +1,374 @@ +"""The main script file for Pyodide.""" + +import frontend +from frontend import CLEAR_BUTTON, EXECUTE_BUTTON, QUERY_INPUT, clear_interface, update_table +from js import Event, document, window +from parser import Parent, ParentKind, Token, TokenKind, Tree, parse, tokenize +from pyodide.ffi import create_proxy +from pyodide.ffi.wrappers import set_timeout + + +def flatten_response(data: dict) -> dict: + """Flatten a dictionary.""" + flattened_result = {} + + def _flatten(current: dict, name: str = "") -> dict: + if isinstance(current, dict): + for field, value in current.items(): + _flatten(value, name + field + "_") + elif isinstance(current, list): + """old code + # for idx, i in enumerate(current): + # _flatten(i, name + str(idx) + "_") + """ + else: + flattened_result[name[:-1].lower()] = current # Drops the extra _ + + _flatten(data) + return flattened_result + + +def blue_screen_of_death() -> None: + """Easter Egg: Show WinXP Blue Screen of Death.""" + input_field = document.getElementById("query-input") + if input_field: + input_field.value = "" + + bsod = document.createElement("div") + bsod.className = "bsod-overlay" + bsod.innerHTML = ( + '
' + '
A problem has been detected and Windows has been shut down to prevent damage ' + " to your computer.
" + '
IRQL_NOT_LESS_OR_EQUAL
' + '
' + " If this is the first time you've seen this stop error screen, " + " restart your computer. If this screen appears again, follow these steps:

" + " Check to make sure any new hardware or software is properly installed. " + " If this is a new installation, ask your hardware or software manufacturer " + " for any Windows updates you might need.

" + " If problems continue, disable or remove any newly installed hardware or software. " + " Disable BIOS memory options such as caching or shadowing. If you need to use " + " Safe Mode to remove or disable components, restart your computer, press F8 " + " to select Advanced Startup Options, and then select Safe Mode." + "
" + '
' + " Technical information:

" + " *** STOP: 0x0000000A (0xFE520004, 0x00000001, 0x00000001, 0x804F9319)

" + " *** Address 804F9319 base at 804D7000, DateStamp 3844d96e - ntoskrnl.exe

" + " Beginning dump of physical memory
" + " Physical memory dump complete.
" + " Contact your system administrator or technical support group for further assistance." + "
" + "
" + ) + + document.body.appendChild(bsod) + frontend.flash_screen("#0000ff", 100) + + def remove_bsod() -> None: + if bsod.parentNode: + document.body.removeChild(bsod) + frontend.update_status("System recovered from critical error", "warning") + frontend.trigger_electric_wave() + + set_timeout(create_proxy(remove_bsod), 4000) + + +def clean_value(text: str) -> str: + """Remove surrounding single/double quotes if present.""" + if isinstance(text, str) and (text[0] == text[-1]) and text[0] in ("'", '"'): + return text[1:-1] + return text + + +def get_text(node: Tree) -> str: + """Recursively get the string value from a node (Parent or Token).""" + if hasattr(node, "text"): + return node.text + if hasattr(node, "children"): + return " ".join(get_text(child) for child in node.children) + return str(node) + + +def walk_where(node: Tree) -> list[tuple | str]: + """Flatten sql expressions into [tuple, 'AND', tuple, ...].""" + if getattr(node, "kind", None).name == "EXPR_BINARY": + left, op, right = node.children + op_text = getattr(op, "text", None) + + if op_text in ("AND", "OR"): + return [*walk_where(left), op_text, *walk_where(right)] + + return [(clean_value(get_text(left)), op_text, clean_value(get_text(right)))] + + if hasattr(node, "children"): + result = [] + for child in node.children: + result.extend(walk_where(child)) + return result + + return [] + + +def get_limit(node: Tree) -> int | None: + """Get what the LIMIT clause of this SQL query contains.""" + assert node.kind is ParentKind.FILE + stmt = node.children[0] + for it in stmt.children: + if it.kind is ParentKind.LIMIT_CLAUSE: + return int(it.children[1].text) + + return None + + +def extract_where(tree: Tree) -> tuple[str, str] | None: + """Extract the where clause from the tree.""" + if not tree.kind == ParentKind.FILE: + raise ValueError + stmt = tree.children[0] + for c in stmt.children: + if c.kind == ParentKind.WHERE_CLAUSE: + return walk_where(c.children[1]) + return [] + + +def extract_fields(tree: Tree) -> list[Token] | None: + """Extract the fields from the tree.""" + if not tree.kind == ParentKind.FILE: + raise ValueError + stmt = tree.children[0] + for c in stmt.children: + if c.kind == ParentKind.FIELD_LIST: + return c.children[::2] + return [] + + +def extract_table(tree: Tree) -> str: + """Extract the Table from the tree.""" + if tree.kind != ParentKind.FILE: + raise ValueError + + stmt = tree.children[0] # SELECT_STMT + for c in stmt.children: + if c.kind == ParentKind.FROM_CLAUSE: + for child in c.children: + if child.kind == TokenKind.IDENTIFIER: + return child.text + break + return "" + + +async def parse_input(_: Event) -> None: + """Start of the parser.""" + query = QUERY_INPUT.value.strip() + + clean_query = query.upper().replace(";", "").replace(",", "").strip() + if "DROP TABLE USERS" in clean_query: + frontend.update_status("what could go wrong?", "warning") + blue_screen_of_death() + return + + tree = parse(tokenize(query)) + if not check_query(tree): + return + + await sql_to_api_handler(tree) + + +async def processor(api: tuple[str, str], table: str, limit: int | None) -> dict: # noqa: C901, PLR0912, PLR0915 + """Process the sql statements into a api call.""" + val = {} + if table == "feed": + if api[0] in ["actor", "author"]: + feed = await window.session.get_author_feed(api[2], limit=limit) + val = feed["feed"] + elif api[0] == "feed": + feed = await window.session.get_feed(api[2], limit=limit) + val = feed["feed"] + + elif table == "timeline": + feed = await window.session.get_timeline() + val = feed["feed"] + elif table == "profile": + if api[0] in ["actor", "author"]: + feed = await window.session.get_profile(api[2]) + val = feed + else: + feed = await window.session.get_profile(None) + if isinstance(feed, dict) and feed.get("stealth_error"): + return "stealth_error" + val = feed + elif table == "suggestions": + feed = await window.session.get_suggestions(limit=limit) + val = feed["actors"] + elif table == "suggested_feed": + feed = await window.session.get_suggested_feeds(limit=limit) + val = feed["feeds"] + elif table == "likes": + if api[0] in ["actor", "author"]: + feed = await window.session.get_actor_likes(api[2], limit=limit) + val = feed["feeds"] + else: + pass + elif table == "followers": + if api[0] in ["actor", "author"]: + feed = await window.session.get_followers(api[2], limit=limit) + val = feed["followers"] + else: + pass + elif table == "following": + if api[0] in ["actor", "author"]: + feed = await window.session.get_follows(api[2], limit=limit) + val = feed["followers"] + else: + pass + elif table == "mutuals": + if api[0] in ["actor", "author"]: + feed = await window.session.get_mutual_follows(api[2], limit=limit) + val = feed["followers"] + else: + pass + elif table == "tables": + val = [ + {"Table_Name": _} + for _ in [ + "feed", + "timeline", + "profile", + "suggestions", + "suggested_feed", + "likes", + "followers", + "following", + "mutuals", + ] + ] + return val + + +def _extract_images_from_post(data: dict) -> str: + """Extract any embedded images from a post and return them as a delimited string.""" + if not isinstance(data, dict): + return "" + + if "post" not in data: + return "" + + post = data["post"] + + # Check if the post has embedded content + if "embed" not in post: + return "" + + embed_type = post["embed"].get("$type", "") + + # Only process image embeds + if embed_type != "app.bsky.embed.images#view": + return "" + + images = post["embed"].get("images", []) + if not images: + return "" + + image_links = [] + for image in images: + image_link = f"{image['thumb']},{image['fullsize']},{image['alt']}" + image_links.append(image_link) + + return " | ".join(image_links) + + +async def sql_to_api_handler(tree: Tree) -> dict: + """Handle going from SQL to the API.""" + where_expr = extract_where(tree) + table = extract_table(tree) + fields = extract_fields(tree) + field_tokens = [i.children[0] for i in fields if i.kind != TokenKind.STAR] + + for i in where_expr: + if i[0] in ["actor", "author", "feed"]: + api = i + break + else: + # No Where Expression Matches + api = ["", ""] + + limit = get_limit(tree) + val = await processor(api, table, limit if limit is not None else 50) + if not val: + frontend.show_empty_table() + frontend.update_status(f"Error getting from {table}. Try: SELECT * FROM tables", "error") # noqa: S608 Not sql injection + frontend.trigger_electric_wave() + return {} + + # Handle stealth mode error for profile queries + if val == "stealth_error": + frontend.show_empty_table() + frontend.update_status( + "Cannot get own profile in stealth mode. Try: SELECT * FROM profile WHERE actors = 'username.bsky.social'", + "warning", + ) + frontend.trigger_electric_wave() + return {} + + if isinstance(val, dict): + val = [val] + + tb = document.getElementById("table-body") + tb.innerHTML = "" + head = [] + if field_tokens: + head = [j.text for j in field_tokens] + body = [] + + for i in val: + data = i + + # Only try to extract images if the data structure supports it + images = _extract_images_from_post(data) + if images and "post" in data: + data["post"]["images"] = images + + d = flatten_response(data) + + if field_tokens: + body.append({j: d.get(j.lower(), "") for j in head}) + else: + body.append(d) + [head.append(k) for k in d if k not in head] + + update_table(head, body) + frontend.update_status(f"Data successfully retrieved from {table}", "success") + return val + + +async def check_query_input(_: Event) -> None: + """Check the query that is currently input.""" + check_query(parse(tokenize(QUERY_INPUT.value.strip()))) + + +def check_query(tree: Tree) -> bool: + """Check a given query and update the status bar.""" + errors = [] + _check_query(tree, errors) + if errors: + frontend.update_status("\n".join(errors), "error") + return False + frontend.update_status("Query is OK", "success") + return True + + +def _check_query(tree: Tree, errors: list[str]) -> None: + """Check a given query recursively.""" + errors.extend([f"- {error}" for error in tree.errors]) + if isinstance(tree, Parent): + for child in tree.children: + _check_query(child, errors) + if tree.kind is ParentKind.ERROR_TREE: + errors.append("- large error") + + +EXECUTE_BUTTON.addEventListener("click", create_proxy(parse_input)) +CLEAR_BUTTON.addEventListener("click", create_proxy(clear_interface)) +QUERY_INPUT.addEventListener("keydown", create_proxy(check_query_input)) diff --git a/iridescent-ivies/src/core/parser.py b/iridescent-ivies/src/core/parser.py new file mode 100644 index 00000000..3599b690 --- /dev/null +++ b/iridescent-ivies/src/core/parser.py @@ -0,0 +1,559 @@ +from __future__ import annotations + +import string +import textwrap +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Literal + + +# tokenizer: +@dataclass +class Token: + """A token produced by tokenization.""" + + kind: TokenKind + text: str + start_pos: int + end_pos: int + errors: list[str] = field(default_factory=list) + + +class TokenKind(Enum): + """What the token represents.""" + + # keywords + SELECT = auto() + FROM = auto() + WHERE = auto() + LIMIT = auto() + + # literals + STRING = auto() + INTEGER = auto() + IDENTIFIER = auto() + STAR = auto() + + # operators + EQUALS = auto() + AND = auto() + GT = auto() + LT = auto() + + # structure + COMMA = auto() + ERROR = auto() + EOF = auto() # this is a fake token only made and used in the parser + + +KEYWORDS = { + "SELECT": TokenKind.SELECT, + "FROM": TokenKind.FROM, + "WHERE": TokenKind.WHERE, + "AND": TokenKind.AND, + "LIMIT": TokenKind.LIMIT, +} + + +@dataclass +class Cursor: + """Helper class to allow peeking into a stream of characters.""" + + contents: str + index: int = 0 + + def peek(self) -> str: + """Look one character ahead in the stream.""" + return self.contents[self.index : self.index + 1] + + def next(self) -> str: + """Get the next character in the stream.""" + c = self.peek() + if c != "": + self.index += 1 + return c + + +def tokenize(query: str) -> list[Token]: # noqa: PLR0912, C901 + """Turn a query into a list of tokens.""" + result = [] + + cursor = Cursor(query) + while True: + idx = cursor.index + char = cursor.next() + + if char == "": + break + + if char in string.ascii_letters: + char = cursor.peek() + + while char in string.ascii_letters + "._": + cursor.next() + char = cursor.peek() + if char == "": + break + + identifier = cursor.contents[idx : cursor.index] + kind = KEYWORDS.get(identifier, TokenKind.IDENTIFIER) + result.append(Token(kind, identifier, idx, cursor.index)) + + elif char in string.digits: + char = cursor.peek() + + while char in string.digits: + cursor.next() + char = cursor.peek() + if char == "": + break + + result.append(Token(TokenKind.INTEGER, cursor.contents[idx : cursor.index], idx, cursor.index)) + + elif char == ",": + result.append(Token(TokenKind.COMMA, ",", idx, cursor.index)) + + elif char == "*": + result.append(Token(TokenKind.STAR, "*", idx, cursor.index)) + + elif char == "'": + # idk escaping rules in SQL lol + char = cursor.peek() + while char != "'": + cursor.next() + char = cursor.peek() + if char == "": + break + + cursor.next() # get the last ' + + string_result = cursor.contents[idx : cursor.index] + kind = TokenKind.STRING if string_result.endswith("'") and len(string_result) > 1 else TokenKind.ERROR + result.append(Token(kind, string_result, idx, cursor.index)) + + elif char == "=": + result.append(Token(TokenKind.EQUALS, "=", idx, cursor.index)) + + elif char == ">": + # TODO: gte? + result.append(Token(TokenKind.GT, ">", idx, cursor.index)) + + elif char == "<": + result.append(Token(TokenKind.LT, "<", idx, cursor.index)) + + return result + + +# parser +# heavily inspired by https://matklad.github.io/2023/05/21/resilient-ll-parsing-tutorial.html +@dataclass +class Parser: + """Helper class that provides useful parser functionality.""" + + contents: list[Token] + events: list[Event] = field(default_factory=list) + index: int = 0 + unreported_errors: list[str] = field(default_factory=list) + + def eof(self) -> bool: + """Check whether the token stream is done.""" + return self.index == len(self.contents) + + def peek(self) -> TokenKind: + """Look at the next kind of token in the stream.""" + if self.eof(): + return TokenKind.EOF + return self.contents[self.index].kind + + def advance(self) -> None: + """Move to the next token in the stream.""" + self.index += 1 + self.events.append("ADVANCE") + + def advance_with_error(self, error: str) -> None: + """Mark the current token as being wrong.""" + if self.eof(): + # this should probably be done better... + self.unreported_errors.append(error) + else: + self.contents[self.index].errors.append(error) + self.advance() + + def open(self) -> int: + """Start nesting children.""" + result = len(self.events) + self.events.append(("OPEN", ParentKind.ERROR_TREE)) + return result + + def open_before(self, index: int) -> int: + """Start nesting children before a given point.""" + self.events.insert(index, ("OPEN", ParentKind.ERROR_TREE)) + return index + + def close(self, kind: ParentKind, where: int) -> int: + """Stop nesting children and note the tree type.""" + self.events[where] = ("OPEN", kind) + self.events.append("CLOSE") + return where + + def expect(self, kind: TokenKind, error: str) -> None: + """Ensure the next token is a specific kind and advance.""" + if self.at(kind): + self.advance() + else: + self.advance_with_error(error) + + def at(self, kind: TokenKind) -> None: + """Check if the next token is a specific kind.""" + return self.peek() == kind + + +@dataclass +class Parent: + """Syntax tree element with children.""" + + kind: ParentKind + children: list[Tree] + errors: list[str] = field(default_factory=list) + + +class ParentKind(Enum): + """Kinds of syntax tree elements that have children.""" + + SELECT_STMT = auto() + ERROR_TREE = auto() + FIELD_LIST = auto() + FROM_CLAUSE = auto() + WHERE_CLAUSE = auto() + LIMIT_CLAUSE = auto() + EXPR_NAME = auto() + EXPR_STRING = auto() + EXPR_INTEGER = auto() + EXPR_BINARY = auto() + FILE = auto() + + +Tree = Parent | Token +Event = Literal["ADVANCE", "CLOSE"] | tuple[Literal["OPEN"], ParentKind] + + +def turn_tokens_into_events(tokens: list[Token]) -> list[Event]: + """Parse a token stream into a list of events.""" + parser = Parser(tokens, []) + while not parser.eof(): + _parse_stmt(parser) + return parser.events, parser.unreported_errors + + +def parse(tokens: list[Token]) -> Tree: + """Parse a token stream into a syntax tree.""" + events, errors = turn_tokens_into_events(tokens) + stack = [("OPEN", ParentKind.FILE)] + events.append("CLOSE") + + i = 0 + for event in events: + if event == "ADVANCE": + stack.append(tokens[i]) + i += 1 + elif event == "CLOSE": + inner = [] + while True: + e = stack.pop() + if isinstance(e, tuple) and e[0] == "OPEN": + inner.reverse() + stack.append(Parent(e[1], inner)) + break + inner.append(e) + else: + assert isinstance(event, tuple) + assert event[0] == "OPEN" + stack.append(event) + + assert i == len(tokens) + assert len(stack) == 1 + result = stack[0] + assert isinstance(result, Tree) + assert result.kind == ParentKind.FILE + result.errors.extend(errors) + return result + + +# free parser functions +def _parse_stmt(parser: Parser) -> None: + # + _parse_select_stmt(parser) + + +def _parse_select_stmt(parser: Parser) -> None: + # 'SELECT' [ ',' ]* [ 'FROM' IDENTIFIER ] [ 'WHERE' ] + start = parser.open() + parser.expect(TokenKind.SELECT, "only SELECT is supported") + + fields_start = parser.open() + _parse_field(parser) + while parser.at(TokenKind.COMMA): + parser.advance() + _parse_field(parser) + parser.close(ParentKind.FIELD_LIST, fields_start) + + if parser.at(TokenKind.FROM): + # from clause + from_start = parser.open() + parser.advance() + + parser.expect(TokenKind.IDENTIFIER, "expected to select from a table") + parser.close(ParentKind.FROM_CLAUSE, from_start) + + if parser.at(TokenKind.WHERE): + # where clause + where_start = parser.open() + parser.advance() + + _parse_expr(parser) + parser.close(ParentKind.WHERE_CLAUSE, where_start) + + if parser.at(TokenKind.LIMIT): + limit_start = parser.open() + parser.advance() + parser.expect(TokenKind.INTEGER, "expected an integer") + parser.close(ParentKind.LIMIT_CLAUSE, limit_start) + + parser.close(ParentKind.SELECT_STMT, start) + + +def _parse_field(parser: Parser) -> None: + # '*' | + if parser.at(TokenKind.STAR): + parser.advance() + else: + _parse_expr(parser) + + +def _parse_expr(parser: Parser) -> None: + # | = + _parse_expr_inner(parser, TokenKind.EOF) + + +def _parse_expr_inner(parser: Parser, left_op: TokenKind) -> None: + left = _parse_small_expr(parser) + + while True: + right_op = parser.peek() + if right_goes_first(left_op, right_op): + # if we have A B C ..., + # then we need to parse (A (B C ...)) + outer = parser.open_before(left) + parser.advance() + _parse_expr_inner(parser, right_op) # (B C ...) + parser.close(ParentKind.EXPR_BINARY, outer) + else: + # (A B) C will be handled + # (if this were toplevel, right_goes_first will happen) + break + + +def _parse_small_expr(parser: Parser) -> int: + # IDENTIFIER + # TODO: it looks like this parser.open() is unnecessary + start = parser.open() + if parser.at(TokenKind.IDENTIFIER): + parser.advance() + return parser.close(ParentKind.EXPR_NAME, start) + if parser.at(TokenKind.STRING): + parser.advance() + return parser.close(ParentKind.EXPR_STRING, start) + if parser.at(TokenKind.INTEGER): + parser.advance() + return parser.close(ParentKind.EXPR_INTEGER, start) + parser.advance_with_error("expected expression") + return parser.close(ParentKind.ERROR_TREE, start) + + +TABLE = [[TokenKind.AND], [TokenKind.EQUALS, TokenKind.GT, TokenKind.LT]] + + +def right_goes_first(left: TokenKind, right: TokenKind) -> bool: + """Understand which token type binds tighter. + + We say that A B C is equivalent to: + - A (B C) if we return True + - (A B) C if we return False + """ + left_idx = next((i for i, r in enumerate(TABLE) if left in r), None) + right_idx = next((i for i, r in enumerate(TABLE) if right in r), None) + + if right_idx is None: + # evaluate left-to-right + return False + if left_idx is None: + # well, maybe left doesn't exist? + assert left == TokenKind.EOF + return True + + return right_idx > left_idx + + +##### tests: (this should be moved to a proper tests folder) + + +def check_tok(before: str, after: TokenKind) -> None: + """Test helper which checks a string tokenizes to a single given token kind.""" + assert [tok.kind for tok in tokenize(before)] == [after] + + +def stringify_tokens(query: str) -> str: + """Test helper which turns a query into a repr of the tokens. + + Used for manual snapshot testing. + """ + tokens = tokenize(query) + result = "" + for i, c in enumerate(query): + for tok in tokens: + if tok.end_pos == i: + result += "<" + + for tok in tokens: + if tok.start_pos == i: + result += ">" + + result += c + + i += 1 + for tok in tokens: + if tok.end_pos == i: + result += "<" + + return result + + +def _stringify_tree(tree: Tree) -> list[str]: + result = [] + if isinstance(tree, Parent): + result.append(f"{tree.kind.name}") + result.extend(" " + line for child in tree.children for line in _stringify_tree(child)) + else: + repr = f'{tree.kind.name} ("{tree.text}")' + if tree.errors: + repr += " -- " + repr += " / ".join(tree.errors) + result.append(repr) + + return result + + +def stringify_tree(tree: Tree) -> str: + """Test helper that turns a syntax tree into a representation of it. + + Used for manual snapshot testing + """ + assert not tree.errors + return "\n".join(_stringify_tree(tree)) + + +def test_simple_tokens() -> None: + """Tests that various things tokenize correct in minimal cases.""" + assert [tok.kind for tok in tokenize("")] == [] + check_tok("SELECT", TokenKind.SELECT) + check_tok("FROM", TokenKind.FROM) + check_tok("WHERE", TokenKind.WHERE) + check_tok("AND", TokenKind.AND) + check_tok("'hello :)'", TokenKind.STRING) + check_tok("12345", TokenKind.INTEGER) + check_tok(",", TokenKind.COMMA) + check_tok("*", TokenKind.STAR) + check_tok("username", TokenKind.IDENTIFIER) + check_tok("username_b", TokenKind.IDENTIFIER) + + +def test_tokenize_simple_select() -> None: + """Tests that tokenization works in more general cases.""" + assert stringify_tokens("SELECT * FROM posts") == ">SELECT< >*< >FROM< >posts<" + + +def test_parse_simple() -> None: + """Tests that parsing works in some specific cases.""" + assert ( + stringify_tree(parse(tokenize("SELECT * FROM posts"))) + == textwrap.dedent(""" + FILE + SELECT_STMT + SELECT ("SELECT") + FIELD_LIST + STAR ("*") + FROM_CLAUSE + FROM ("FROM") + IDENTIFIER ("posts") + """).strip() + ) + + assert ( + stringify_tree(parse(tokenize("SELECT * WHERE actor = 'aaa'"))) + == textwrap.dedent(""" + FILE + SELECT_STMT + SELECT ("SELECT") + FIELD_LIST + STAR ("*") + WHERE_CLAUSE + WHERE ("WHERE") + EXPR_BINARY + EXPR_NAME + IDENTIFIER ("actor") + EQUALS ("=") + EXPR_STRING + STRING ("'aaa'") + """).strip() + ) + + assert ( + stringify_tree(parse(tokenize("SELECT 4 WHERE actor = 'a' AND likes > 10"))) + == textwrap.dedent(""" + FILE + SELECT_STMT + SELECT ("SELECT") + FIELD_LIST + EXPR_INTEGER + INTEGER ("4") + WHERE_CLAUSE + WHERE ("WHERE") + EXPR_BINARY + EXPR_BINARY + EXPR_NAME + IDENTIFIER ("actor") + EQUALS ("=") + EXPR_STRING + STRING ("'a'") + AND ("AND") + EXPR_BINARY + EXPR_NAME + IDENTIFIER ("likes") + GT (">") + EXPR_INTEGER + INTEGER ("10") + """).strip() + ) + + assert ( + stringify_tree(parse(tokenize("SELECT 4 LIMIT 0"))) + == textwrap.dedent(""" + FILE + SELECT_STMT + SELECT ("SELECT") + FIELD_LIST + EXPR_INTEGER + INTEGER ("4") + LIMIT_CLAUSE + LIMIT ("LIMIT") + INTEGER ("0") + """).strip() + ) + + +if __name__ == "__main__": + query = input("query> ") + print(stringify_tokens(query)) + + print() + print(stringify_tree(parse(tokenize(query)))) diff --git a/iridescent-ivies/src/core/setup.py b/iridescent-ivies/src/core/setup.py new file mode 100644 index 00000000..812eda31 --- /dev/null +++ b/iridescent-ivies/src/core/setup.py @@ -0,0 +1,34 @@ +"""The Setup Script for pyodide.""" + +from pathlib import Path + +import micropip +from pyodide.http import pyfetch + + +async def setup_pyodide_scripts() -> None: + """Script to do everything for pyodide.""" + response = await pyfetch("./core/functions.py") + with Path.open("functions.py", "wb") as f: + f.write(await response.bytes()) + + response = await pyfetch("./core/parser.py") + with Path.open("parser.py", "wb") as f: + f.write(await response.bytes()) + + response = await pyfetch("./ui/image_modal.py") + with Path.open("image_modal.py", "wb") as f: + f.write(await response.bytes()) + + response = await pyfetch("./ui/frontend.py") + with Path.open("frontend.py", "wb") as f: + f.write(await response.bytes()) + await micropip.install("ascii_magic") + + response = await pyfetch("./api/auth_session.py") + with Path.open("auth_session.py", "wb") as f: + f.write(await response.bytes()) + + response = await pyfetch("./ui/auth_modal.py") + with Path.open("auth_modal.py", "wb") as f: + f.write(await response.bytes()) diff --git a/iridescent-ivies/src/index.html b/iridescent-ivies/src/index.html new file mode 100644 index 00000000..6a06811a --- /dev/null +++ b/iridescent-ivies/src/index.html @@ -0,0 +1,310 @@ + + + + + + The Social Query Language v1.0 + + + + + + + +
+ +
+ +
+
+ The Social Query Language v1.0 + © 1987 Iridescent Ivies +
+ +
+
+ +
+
SQL COMMAND
+
+ +
+ + + +
+
+
+ + +
+
QUERY RESULTS
+ + +
+ + + + + + + + + + +
+ No data loaded. Execute a query to see results. +
+
+ + +
+
+ Rows: 0 | Status: Waiting +
+
+
+
+
+
+ + +
+
+
+
EXECUTING QUERY...
+
+
+ + +
+ +
+ + +
+ +
+ + + + + diff --git a/iridescent-ivies/src/ui/__init__.py b/iridescent-ivies/src/ui/__init__.py new file mode 100644 index 00000000..a3096e30 --- /dev/null +++ b/iridescent-ivies/src/ui/__init__.py @@ -0,0 +1 @@ +# The front end elements for SQL diff --git a/iridescent-ivies/src/ui/auth_modal.py b/iridescent-ivies/src/ui/auth_modal.py new file mode 100644 index 00000000..40ece8fb --- /dev/null +++ b/iridescent-ivies/src/ui/auth_modal.py @@ -0,0 +1,352 @@ +from js import Element, Event, document, window +from pyodide.ffi import create_proxy +from pyodide.ffi.wrappers import set_timeout + +try: + import frontend +except ImportError: + frontend = None +from auth_session import BskySession + +# dom +AUTH_MODAL = None +LOGIN_BTN = None +STEALTH_BTN = None +CRT_TOGGLE_BTN = None +USERNAME_INPUT = None +PASSWORD_INPUT = None +AUTH_FORM = None +STATUS_TEXT = None + +# authentication data +auth_data = None +is_modal_visible = False +crt_enabled = True # CRT starts enabled by default + + +def init_auth_modal() -> None: + """Initialize the authentication modal.""" + global AUTH_MODAL, LOGIN_BTN, STEALTH_BTN, USERNAME_INPUT, PASSWORD_INPUT, AUTH_FORM, STATUS_TEXT, CRT_TOGGLE_BTN # noqa: PLW0603 + + AUTH_MODAL = document.getElementById("auth-modal") + LOGIN_BTN = document.getElementById("login-btn") + STEALTH_BTN = document.getElementById("stealth-btn") + CRT_TOGGLE_BTN = document.getElementById("crt-toggle-btn") + USERNAME_INPUT = document.getElementById("bluesky-username") + PASSWORD_INPUT = document.getElementById("bluesky-password") + AUTH_FORM = document.getElementById("auth-form") + STATUS_TEXT = document.getElementById("security-notice") + + setup_event_listeners() + print("Auth modal initialized") + + +def setup_event_listeners() -> None: + """Configure event listeners for the modal.""" + USERNAME_INPUT.addEventListener("input", create_proxy(on_input_change)) + PASSWORD_INPUT.addEventListener("input", create_proxy(on_input_change)) + AUTH_FORM.addEventListener("submit", create_proxy(on_form_submit)) + STEALTH_BTN.addEventListener("click", create_proxy(on_stealth_click)) + + # Configure CRT toggle button event listener + if CRT_TOGGLE_BTN: + CRT_TOGGLE_BTN.addEventListener("click", create_proxy(on_crt_toggle_click)) + print("CRT toggle event listener attached") + else: + print("ERROR: CRT toggle button not found!") + + document.addEventListener("keydown", create_proxy(on_keydown)) + + +def on_input_change(event: Event) -> None: + """Trigger visual effects on input change.""" + target = event.target + target.style.borderColor = "#66ff66" + + def reset_border() -> None: + target.style.borderColor = "#00ff00" + + set_timeout(create_proxy(reset_border), 300) + + +def on_form_submit(event: Event) -> None: + """Handle form submission.""" + event.preventDefault() + handle_authentication() + + +def on_stealth_click(_event: Event) -> None: + """Handle click on stealth mode.""" + handle_stealth_mode() + + +def on_crt_toggle_click(_event: Event) -> None: + """Handle CRT effect toggle.""" + print("CRT toggle button clicked") + toggle_crt_effect() + + +def on_keydown(event: Event) -> None: + """Keyboard shortcuts - not visually indicated *yet?.""" + if not is_modal_visible: + return + + if event.key == "Escape": + handle_stealth_mode() + elif event.key == "Enter" and (event.ctrlKey or event.metaKey): + handle_authentication() + + +def toggle_crt_effect() -> None: + """Toggle the CRT effect on/off.""" + global crt_enabled # noqa: PLW0603 + + print(f"Toggle CRT called. Current state: {crt_enabled}") + + if not CRT_TOGGLE_BTN: + print("ERROR: CRT toggle button is None!") + return + + body = document.body + + if crt_enabled: + # Disable CRT effect + body.classList.remove("crt") + CRT_TOGGLE_BTN.innerHTML = "CRT EFFECT: OFF" + CRT_TOGGLE_BTN.style.background = "#333300" + CRT_TOGGLE_BTN.style.borderColor = "#ffff00" + CRT_TOGGLE_BTN.style.color = "#ffff00" + crt_enabled = False + print("CRT effect disabled") + else: + # Enable CRT effect + body.classList.add("crt") + CRT_TOGGLE_BTN.innerHTML = "CRT EFFECT: ON" + CRT_TOGGLE_BTN.style.background = "#003300" + CRT_TOGGLE_BTN.style.borderColor = "#00ff00" + CRT_TOGGLE_BTN.style.color = "#00ff00" + crt_enabled = True + print("CRT effect enabled") + + CRT_TOGGLE_BTN.style.transform = "scale(0.95)" + + def reset_scale() -> None: + CRT_TOGGLE_BTN.style.transform = "scale(1.0)" + + set_timeout(create_proxy(reset_scale), 150) + + +def show_modal() -> None: + """Show the modal.""" + global is_modal_visible # noqa: PLW0603 + + if AUTH_MODAL: + AUTH_MODAL.classList.add("show") + is_modal_visible = True + + def focus_username() -> None: + if USERNAME_INPUT: + USERNAME_INPUT.focus() + + set_timeout(create_proxy(focus_username), 500) + print("Auth modal shown") + + +def hide_modal() -> None: + """Hide the modal.""" + if AUTH_MODAL: + AUTH_MODAL.style.opacity = "0" + + def complete_hide() -> None: + global is_modal_visible # noqa: PLW0603 + AUTH_MODAL.classList.remove("show") + AUTH_MODAL.style.display = "none" + is_modal_visible = False + + set_timeout(create_proxy(complete_hide), 500) + print("Auth modal hidden") + + +def handle_authentication() -> None: + """Capture authentication data.""" + username = USERNAME_INPUT.value.strip() + password = PASSWORD_INPUT.value.strip() + + if not username or not password: + show_input_error() + return + + LOGIN_BTN.disabled = True + LOGIN_BTN.innerHTML = 'AUTHENTICATING' + + print(f"Capturing auth data for: {username}") + + async def complete_auth() -> None: + global auth_data # noqa: PLW0603 + + # catch and store authentication data + auth_data = {"username": username, "password": password, "mode": "authenticated"} + window.session = BskySession(username, password) + is_logged_in = await window.session.login() + if not is_logged_in: + handle_failed_auth() + return + LOGIN_BTN.innerHTML = "AUTHENTICATED ✓" + LOGIN_BTN.style.background = "#004400" + + def finish_auth() -> None: + hide_modal() + on_auth_complete(auth_data) + + set_timeout(create_proxy(finish_auth), 1000) + + set_timeout(create_proxy(complete_auth), 2000) + + +def handle_failed_auth() -> None: + """Handle a failed login.""" + LOGIN_BTN.disabled = False + LOGIN_BTN.innerHTML = "LOGIN" + USERNAME_INPUT.style.borderColor = "#ff0000" + PASSWORD_INPUT.style.borderColor = "#ff0000" + PASSWORD_INPUT.value = "" + STATUS_TEXT.innerText = "Incorrect Username or Password." + + def reset_status() -> None: + STATUS_TEXT.innerText = "Your credentials are processed locally and never stored permanently. \ + Stealth mode allows read-only access to public posts." + USERNAME_INPUT.style.borderColor = "#00ff00" + PASSWORD_INPUT.style.borderColor = "#00ff00" + + set_timeout(create_proxy(reset_status), 2000) + + +def handle_stealth_mode() -> None: + """Enable stealth/anonymous mode.""" + STEALTH_BTN.disabled = True + STEALTH_BTN.innerHTML = 'INITIALIZING STEALTH' + + print("Entering stealth mode") + + def complete_stealth() -> None: + global auth_data # noqa: PLW0603 + + # save stealth mode + auth_data = {"mode": "stealth"} + window.session = BskySession("", "") + STEALTH_BTN.innerHTML = "STEALTH ACTIVE ✓" + STEALTH_BTN.style.background = "#444400" + + def finish_stealth() -> None: + hide_modal() + on_auth_complete(auth_data) + + set_timeout(create_proxy(finish_stealth), 1000) + + set_timeout(create_proxy(complete_stealth), 1500) + + +def show_input_error() -> None: + """Show visual error on empty fields.""" + for input_field in [USERNAME_INPUT, PASSWORD_INPUT]: + if not input_field.value.strip(): + input_field.style.borderColor = "#ff0000" + input_field.style.boxShadow = "inset 0 0 10px rgba(255, 0, 0, 0.3)" + + def reset_field_style(field: Element = input_field) -> None: + field.style.borderColor = "#00ff00" + field.style.boxShadow = "" + + set_timeout(create_proxy(reset_field_style), 1000) + + +def on_auth_complete(auth_result: dict) -> None: + """Complete authentication and show interface.""" + safe_to_print = auth_result.copy() + + if safe_to_print["mode"] != "stealth": + safe_to_print["password"] = "********" # noqa: S105 + print(f"Authentication completed: {safe_to_print}") + + # update global JavaScript state + if hasattr(window, "AppState"): + window.AppState.authData = auth_result + window.AppState.isAuthenticated = auth_result["mode"] == "authenticated" + + # show main interface + main_interface = document.querySelector(".interface") + if main_interface: + main_interface.style.transition = "opacity 0.5s ease" + main_interface.style.opacity = "1" + + # update the frontend if it is available + if frontend is not None: + mode = "authenticated user" if auth_result["mode"] == "authenticated" else "stealth mode" + frontend.update_status(f"Connected as {mode}", "success") + frontend.update_connection_info(0, mode) + frontend.trigger_electric_wave() + else: + print("Frontend module not available yet") + + +# functions to use in other modules + + +def get_auth_data() -> dict | None: + """Get all authentication data.""" + return auth_data + + +def is_authenticated() -> bool: + """Verify if the user is authenticated.""" + return auth_data is not None and auth_data.get("mode") == "authenticated" + + +def get_username() -> str | None: + """Get username of authenticated user.""" + if is_authenticated(): + return auth_data.get("username") + return None + + +def get_password() -> str | None: + """Get password of authenticated user.""" + if is_authenticated(): + return auth_data.get("password") + return None + + +def get_auth_mode() -> str: + """Get mode: 'authenticated', 'stealth', 'none'.""" + if auth_data: + return auth_data.get("mode", "none") + return "none" + + +def is_stealth_mode() -> bool: + """Verify if it is in stealth mode.""" + return auth_data is not None and auth_data.get("mode") == "stealth" + + +def is_crt_enabled() -> bool: + """Check if CRT effect is currently enabled.""" + return crt_enabled + + +def get_crt_status() -> str: + """Get current CRT status as string.""" + return "ON" if crt_enabled else "OFF" + + +def show_auth_modal_after_boot() -> None: + """Show modal after boot sequence.""" + print("Initializing authentication modal...") + init_auth_modal() + + def delayed_show() -> None: + show_modal() + + set_timeout(create_proxy(delayed_show), 200) + + +print("Auth modal module loaded") diff --git a/iridescent-ivies/src/ui/frontend.py b/iridescent-ivies/src/ui/frontend.py new file mode 100644 index 00000000..257653eb --- /dev/null +++ b/iridescent-ivies/src/ui/frontend.py @@ -0,0 +1,339 @@ +from typing import Literal + +from image_modal import show_image_modal +from js import Element, Event, Math, document +from pyodide.ffi import create_proxy +from pyodide.ffi.wrappers import set_interval, set_timeout + +# constants for random effects +ELECTRIC_WAVE_PROBABILITY = 0.03 +SCREEN_FLICKER_PROBABILITY = 0.05 + +QUERY_INPUT = document.getElementById("query-input") +EXECUTE_BUTTON = document.getElementById("execute-btn") +CANCEL_BUTTON = document.getElementById("cancel-btn") +CLEAR_BUTTON = document.getElementById("clear-btn") +TABLE_HEAD = document.getElementById("table-head") +TABLE_BODY = document.getElementById("table-body") +STATUS_MESSAGE = document.getElementById("status-message") +CONNECTION_INFO = document.getElementById("connection-info") +LOADING_OVERLAY = document.getElementById("loading-overlay") +ELECTRIC_WAVE = document.getElementById("electric-wave") + + +def electric_wave_trigger() -> None: + """Roll to see if you will activate the electric wave.""" + if Math.random() < ELECTRIC_WAVE_PROBABILITY: + ELECTRIC_WAVE.classList.remove("active") + + def _activate() -> None: + ELECTRIC_WAVE.classList.add("active") + + set_timeout(create_proxy(_activate), 50) + + +def update_status(message: str, stat_type: Literal["success", "error", "warning", "info"] = "info") -> None: + """Update the status with a given message.""" + STATUS_MESSAGE.textContent = message + STATUS_MESSAGE.className = f"status-{stat_type}" + + # blink effect for errors + if stat_type == "error": + STATUS_MESSAGE.style.animation = "blink 0.5s 3" + + def _deactivate() -> None: + STATUS_MESSAGE.style.animation = "" + + set_timeout(create_proxy(_deactivate), 1500) + + +def clear_query_input() -> None: + """Clear the Query field.""" + QUERY_INPUT.style.opacity = "0.3" + + def _clear() -> None: + QUERY_INPUT.value = "" + QUERY_INPUT.style.transition = "opacity 0.3s ease" + QUERY_INPUT.style.opacity = "1" + + set_timeout(create_proxy(_clear), 150) + + +def show_empty_table() -> int: + """Empty the table.""" + empty_row = document.createElement("tr") + empty_cell = document.createElement("td") + empty_cell.textContent = "no data found" + empty_cell.colSpan = 8 + empty_cell.style.textAlign = "center" + empty_cell.style.padding = "40px 20px" + empty_cell.style.color = "#666" + empty_cell.style.fontStyle = "italic" + empty_row.appendChild(empty_cell) + + TABLE_HEAD.innerHTML = "No Columns" + TABLE_BODY.replaceChildren(empty_row) + + TABLE_HEAD.style.opacity = "1" + TABLE_BODY.style.opacity = "1" + update_connection_info(0, "no results") + return 1 + + +def update_connection_info(row: int, status: str) -> None: + """Update the connection info.""" + CONNECTION_INFO.textContent = f"rows: {row} | status: {status}" + + +def clear_interface(_: Event) -> None: + """Clear the user interface.""" + clear_query_input() + show_empty_table() + update_status("interface cleared", "info") + update_connection_info(0, "waiting") + + +def show_loading(*, show: bool = True) -> None: + """Show/hide loading overlay with spinner.""" + if show: + LOADING_OVERLAY.classList.add("show") + trigger_electric_wave() # automatic effect when loading + else: + LOADING_OVERLAY.classList.remove("show") + + +def trigger_electric_wave() -> None: + """Trigger the electric wave effect.""" + ELECTRIC_WAVE.classList.remove("active") + + def _activate() -> None: + ELECTRIC_WAVE.classList.add("active") + + set_timeout(create_proxy(_activate), 50) + + +def _create_table_headers(headers: list) -> None: + """Create table headers with staggered animation.""" + header_row = document.createElement("tr") + for index, header in enumerate(headers): + th = document.createElement("th") + th.textContent = header.upper() + th.style.opacity = "0" + header_row.appendChild(th) + + # staggered header animation + def _show_header(element: Element = th, delay: int = index * 50) -> None: + def _animate() -> None: + element.style.transition = "opacity 0.3s ease" + element.style.opacity = "1" + + set_timeout(create_proxy(_animate), delay) + + _show_header() + + TABLE_HEAD.appendChild(header_row) + + +EMBED_IMAGE_LEN = 3 + + +def _handle_image(image: str) -> Element: + """Take in an image string and create the hyperlink element. + + The string is expected to be either 1 link or a comma-separated list of 3. + """ + items = image.split(",") + thumbnail_link = items[0] + full_size_link = "" + alt_text = "" + # handle embedded images vs profile pics + if len(items) == EMBED_IMAGE_LEN: + full_size_link = items[1] + alt_text = items[2] + hyperlink = document.createElement("a") + hyperlink.href = "#" + hyperlink.textContent = "Image" + + def create_click_handler(img_url: str, fullsize_url: str, alt: str) -> callable: + """Capture the image value. + + without this there is a weird issue where all of the images are the same. + """ + + async def _handler( + _: Event, img_url: str = img_url, fullsize_url: str = fullsize_url, alt: str = alt + ) -> callable: + await show_image_modal(img_url, fullsize_url, alt) + + return _handler + + hyperlink.addEventListener("click", create_proxy(create_click_handler(thumbnail_link, full_size_link, alt_text))) + return hyperlink + + +def _create_table_rows(headers: list, rows: list[dict]) -> None: + """Create table rows with appearing effect.""" + for row_index, row_data in enumerate(rows): + tr = document.createElement("tr") + tr.style.opacity = "0" + + cell_values = [str(row_data.pop(header, "")) for header in headers] + for cell_data in cell_values: + td = document.createElement("td") + # handle image links + if cell_data.startswith("https://cdn.bsky.app/img/"): + images = cell_data.split(" | ") + for image in images: + image_element = _handle_image(image) + td.append(image_element) + else: + td.textContent = str(cell_data) if cell_data else "" + tr.appendChild(td) + + TABLE_BODY.appendChild(tr) + + # staggered row animation + def _show_row(element: Element = tr, delay: int = (row_index * 100) + 200) -> None: + def _animate() -> None: + element.style.transition = "opacity 0.4s ease" + element.style.opacity = "1" + + set_timeout(create_proxy(_animate), delay) + + _show_row() + + +def update_table(headers: list, rows: list[dict]) -> None: + """Populate table with data and appearing effects.""" + # fade out effect before updating + TABLE_HEAD.style.opacity = "0.3" + TABLE_BODY.style.opacity = "0.3" + + def _update_content() -> None: + # clear table + TABLE_HEAD.innerHTML = "" + TABLE_BODY.innerHTML = "" + + if not headers or not rows or len(rows) == 0: + show_empty_table() + return + + _create_table_headers(headers) + _create_table_rows(headers, rows) + + # restore container opacity + def _restore_opacity() -> None: + TABLE_HEAD.style.opacity = "1" + TABLE_BODY.style.opacity = "1" + + set_timeout(create_proxy(_restore_opacity), 300) + + # update counter + update_connection_info(len(rows), "connected") + + # final success effect + def _final_effect() -> None: + trigger_electric_wave() + + set_timeout(create_proxy(_final_effect), (len(rows) * 100) + 500) + + set_timeout(create_proxy(_update_content), 200) + + +def set_buttons_disabled(*, disabled: bool) -> None: + """Enable/disable buttons with visual effects.""" + EXECUTE_BUTTON.disabled = disabled + CLEAR_BUTTON.disabled = disabled + CANCEL_BUTTON.disabled = not disabled # cancel only available when executing + + # visual effects on buttons + if disabled: + EXECUTE_BUTTON.style.opacity = "0.5" + CLEAR_BUTTON.style.opacity = "0.5" + CANCEL_BUTTON.style.opacity = "1" + CANCEL_BUTTON.style.animation = "blink 1s infinite" + else: + EXECUTE_BUTTON.style.opacity = "1" + CLEAR_BUTTON.style.opacity = "1" + CANCEL_BUTTON.style.opacity = "0.7" + CANCEL_BUTTON.style.animation = "" + + +def get_current_query() -> str: + """Get current query from input.""" + return QUERY_INPUT.value.strip() + + +def show_input_error() -> None: + """Error effect on input field.""" + QUERY_INPUT.style.borderColor = "#ff0000" + QUERY_INPUT.style.boxShadow = "inset 0 0 10px rgba(255, 0, 0, 0.3)" + + def _reset() -> None: + QUERY_INPUT.style.borderColor = "#00ff00" + QUERY_INPUT.style.boxShadow = "inset 0 0 5px rgba(0, 255, 0, 0.3)" + + set_timeout(create_proxy(_reset), 1000) + + +def show_input_success() -> None: + """Success effect on input field.""" + QUERY_INPUT.style.borderColor = "#00ff00" + QUERY_INPUT.style.boxShadow = "inset 0 0 10px rgba(0, 255, 0, 0.5)" + + def _reset() -> None: + QUERY_INPUT.style.boxShadow = "inset 0 0 5px rgba(0, 255, 0, 0.3)" + + set_timeout(create_proxy(_reset), 1000) + + +def flash_screen(color: str = "#00ff00", duration: int = 200) -> None: + """Fullscreen flash effect for important results.""" + flash = document.createElement("div") + flash.style.position = "fixed" + flash.style.top = "0" + flash.style.left = "0" + flash.style.width = "100%" + flash.style.height = "100%" + flash.style.backgroundColor = color + flash.style.opacity = "0.1" + flash.style.pointerEvents = "none" + flash.style.zIndex = "9999" + + document.body.appendChild(flash) + + def _fade_out() -> None: + flash.style.transition = f"opacity {duration}ms ease" + flash.style.opacity = "0" + + def _remove() -> None: + document.body.removeChild(flash) + + set_timeout(create_proxy(_remove), duration) + + set_timeout(create_proxy(_fade_out), 50) + + +def screen_flicker_effect() -> None: + """Occasional screen flicker (retro effect).""" + if Math.random() < SCREEN_FLICKER_PROBABILITY: + screen = document.querySelector(".screen") + if screen: + screen.style.opacity = "0.9" + + def _restore() -> None: + screen.style.opacity = "1" + + set_timeout(create_proxy(_restore), 100) + + +# automatic system effects setup +set_interval(create_proxy(electric_wave_trigger), 1000) +set_interval(create_proxy(screen_flicker_effect), 5000) + +# setup initial ui +update_status("system ready", "success") +update_connection_info(0, "waiting") +show_empty_table() + +print("ready") diff --git a/iridescent-ivies/src/ui/image_modal.py b/iridescent-ivies/src/ui/image_modal.py new file mode 100644 index 00000000..8b36e2c8 --- /dev/null +++ b/iridescent-ivies/src/ui/image_modal.py @@ -0,0 +1,51 @@ +from io import BytesIO + +from ascii_magic import AsciiArt +from js import Event, document, window +from pyodide.ffi import create_proxy +from pyodide.http import pyfetch + +IMAGE_MODAL = document.getElementById("image-modal") +ASCII_DISPLAY = document.getElementById("ascii-display") +ALT_TEXT = document.getElementById("image-alt-text") +FULL_SIZE_LINK = document.getElementById("image-modal-full-link") +CLOSE_BUTTON = document.getElementById("image-modal-close") + +_image_cache = {} + + +async def show_image_modal(thumb_link: str, fullsize_link: str, alt: str) -> None: + """Show the image modal with the given link.""" + IMAGE_MODAL.style.display = "block" + FULL_SIZE_LINK.href = fullsize_link or thumb_link + ALT_TEXT.textContent = alt + ASCII_DISPLAY.textContent = "" + ascii_img = await load_image(thumb_link) + ASCII_DISPLAY.textContent = ascii_img + + +def hide_image_modal(_: Event) -> None: + """Hide the image modal.""" + IMAGE_MODAL.style.display = "none" + ASCII_DISPLAY.textContent = "" + + +# TODO: Fix styling ;) + + +async def load_image(url: str) -> str: + """Load an image as monochrome ascii.""" + if url in _image_cache: # "Cache" the images for speeding up things. + return _image_cache[url] + + blob_url = await window.session.get_blob(url) + res = await pyfetch(blob_url) + bites = BytesIO(await res.bytes()) + ascii_image = AsciiArt.from_image(bites) + ascii_image = AsciiArt.from_image(bites) + ascii_str = ascii_image.to_ascii(columns=100, monochrome=True) + _image_cache[url] = ascii_str + return ascii_str + + +CLOSE_BUTTON.addEventListener("click", create_proxy(hide_image_modal)) diff --git a/iridescent-ivies/src/web/boot.js b/iridescent-ivies/src/web/boot.js new file mode 100644 index 00000000..19c8a676 --- /dev/null +++ b/iridescent-ivies/src/web/boot.js @@ -0,0 +1,437 @@ +// boot.js +// exposes boot progress API for actual pyodide loading + +const bootMessages = [ + { + text: "BIOS Version 2.1.87 - Copyright (C) 1987 Iridescent Ivies", + delay: 500, + stage: "bios" + }, + { text: "Memory Test: 640K OK", delay: 400, stage: "memory" }, + { text: "Extended Memory Test: 15360K OK", delay: 300, stage: "memory" }, + { text: "", delay: 200, stage: "memory" }, + { text: "Detecting Hardware...", delay: 400, stage: "hardware" }, + { text: " - Primary Hard Disk.......... OK", delay: 300, stage: "hardware" }, + { text: " - Network Interface.......... OK", delay: 300, stage: "hardware" }, + { text: " - Math Coprocessor........... OK", delay: 200, stage: "hardware" }, + { text: "", delay: 200, stage: "hardware" }, + { text: "Loading SQL Social Network v1.0...", delay: 400, stage: "init" }, + { text: "Initializing Python Runtime Environment...", delay: 300, stage: "pyodide_start" }, + { text: "Loading Pyodide Kernel", delay: 0, showProgress: true, stage: "pyodide_load", waitForCallback: true }, + { text: "Installing setup scripts...", delay: 0, showProgress: true, stage: "setup_load", waitForCallback: true }, + { text: "Configuring Python modules...", delay: 0, showProgress: true, stage: "modules_load", waitForCallback: true }, + { text: "Loading parser and functions...", delay: 0, showProgress: true, stage: "functions_load", waitForCallback: true }, + { text: "Establishing database connections...", delay: 400, stage: "db_init" }, + { text: "Loading sample datasets...", delay: 300, stage: "data_init" }, + { text: "", delay: 200, stage: "complete" }, + { text: "System Ready!", delay: 300, blink: true, stage: "complete" }, + { text: "Press any key to continue...", delay: 500, blink: true, stage: "complete" }, +]; + +let bootScreen = null; +let isBootComplete = false; +let continuePressed = false; +let currentMessageIndex = 0; +let bootContent = null; +let progressCallbacks = {}; + + +window.bootProgress = { + start: startBootSequence, + pyodideLoaded: () => advanceToStage("setup_load"), + setupLoaded: () => advanceToStage("modules_load"), + modulesLoaded: () => advanceToStage("functions_load"), + functionsLoaded: () => advanceToStage("db_init"), + complete: finishBoot, + isComplete: () => isBootComplete, + + updateProgress: updateCurrentProgress, + setProgressMessage: setProgressMessage +}; + +// update current progress bar to a specific percentage +function updateCurrentProgress(percentage) { + const currentProgressBars = document.querySelectorAll('[id^="progress-bar-"]'); + const lastBar = currentProgressBars[currentProgressBars.length - 1]; + if (lastBar) { + // clear any existing interval for this bar to prevent conflicts + const barId = lastBar.id; + if (progressCallbacks[barId]) { + clearInterval(progressCallbacks[barId]); + delete progressCallbacks[barId]; + } + + lastBar.style.width = Math.min(85, Math.max(0, percentage)) + "%"; + } +} + +// add a custom progress message (optional) +function setProgressMessage(message) { + const bootLines = bootContent.querySelectorAll('.boot-line'); + const lastLine = bootLines[bootLines.length - 1]; + if (lastLine && !lastLine.classList.contains('boot-blink')) { + const progressDiv = lastLine.querySelector('.boot-progress'); + if (progressDiv) { + lastLine.innerHTML = message + progressDiv.outerHTML; + } + } +} + +async function startBootSequence() { + continuePressed = false; + currentMessageIndex = 0; + + bootScreen = document.createElement("div"); + bootScreen.className = "boot-screen"; + bootScreen.innerHTML = '
'; + + document.body.appendChild(bootScreen); + document.querySelector(".interface").style.opacity = "0"; + + bootContent = document.getElementById("boot-content"); + + // start showing messages up to first callback point + await showMessagesUpToStage("pyodide_load"); + + console.log("Boot sequence waiting for pyodide load..."); +} + +async function showMessagesUpToStage(targetStage) { + while (currentMessageIndex < bootMessages.length) { + if (continuePressed) { + // fast-forward remaining messages + showRemainingMessages(); + break; + } + + const message = bootMessages[currentMessageIndex]; + + // stop if we hit a callback stage that doesn't match the target + if (message.waitForCallback && message.stage !== targetStage) { + break; + } + + await showMessage(message, currentMessageIndex); + currentMessageIndex++; + + // if this was the target stage and it's a callback, stop here + if (message.stage === targetStage && message.waitForCallback) { + break; + } + } +} + +async function advanceToStage(targetStage) { + console.log(`Advancing boot to stage: ${targetStage}`); + + // complete current progress bar smoothly if there is one + await completeCurrentProgressBar(); + + // continue to next stage + currentMessageIndex++; + await showMessagesUpToStage(targetStage); +} + +function completeCurrentProgressBar() { + return new Promise((resolve) => { + const currentProgressBars = document.querySelectorAll('[id^="progress-bar-"]'); + if (currentProgressBars.length === 0) { + resolve(); + return; + } + + let completed = 0; + currentProgressBars.forEach(bar => { + const barId = bar.id; + + if (progressCallbacks[barId]) { + clearInterval(progressCallbacks[barId]); + delete progressCallbacks[barId]; + } + + const currentWidth = parseFloat(bar.style.width) || 0; + + if (currentWidth >= 100) { + completed++; + if (completed === currentProgressBars.length) { + resolve(); + } + return; + } + + const interval = setInterval(() => { + const width = parseFloat(bar.style.width) || 0; + if (width >= 100) { + bar.style.width = "100%"; + clearInterval(interval); + completed++; + if (completed === currentProgressBars.length) { + resolve(); + } + } else { + bar.style.width = Math.min(100, width + 12) + "%"; + } + }, 30); + }); + }); +} + +async function showMessage(message, index) { + const line = document.createElement("div"); + line.className = "boot-line"; + + if (message.showProgress) { + line.innerHTML = message.text + + '
'; + } else { + line.textContent = message.text; + } + + if (message.blink) { + line.classList.add("boot-blink"); + } + + bootContent.appendChild(line); + + setTimeout(() => { + line.classList.add("boot-show"); + }, 50); + + if (message.showProgress && !message.waitForCallback) { + await animateProgressBar("progress-bar-" + index); + } else if (message.showProgress && message.waitForCallback) { + startProgressBar("progress-bar-" + index); + } + + if (message.delay > 0) { + await new Promise(resolve => setTimeout(resolve, message.delay)); + } +} + +function startProgressBar(barId) { + const progressBar = document.getElementById(barId); + if (!progressBar) return; + + // start at 0% and slowly increase until callback + let progress = 0; + progressBar.style.width = progress + "%"; + + const interval = setInterval(() => { + if (continuePressed) { + progress = 100; + progressBar.style.width = progress + "%"; + clearInterval(interval); + return; + } + + // slowly increase but never complete without callback + // slow down as it gets closer to 90% + const increment = progress < 50 ? Math.random() * 8 : Math.random() * 2; + progress += increment; + if (progress > 85) progress = 85; + progressBar.style.width = progress + "%"; + }, 300); + + progressCallbacks[barId] = interval; +} + +function animateProgressBar(barId) { + return new Promise((resolve) => { + const progressBar = document.getElementById(barId); + if (!progressBar) { + resolve(); + return; + } + + let progress = 0; + const interval = setInterval(() => { + if (continuePressed) { + progress = 100; + clearInterval(interval); + resolve(); + return; + } + + progress += Math.random() * 15; + if (progress >= 100) { + progress = 100; + clearInterval(interval); + resolve(); + } + progressBar.style.width = progress + "%"; + }, 100); + }); +} + +function showRemainingMessages() { + const remainingMessages = bootMessages.slice(currentMessageIndex); + remainingMessages.forEach((msg, i) => { + const line = document.createElement("div"); + line.className = "boot-line boot-show"; + line.textContent = msg.text; + if (msg.blink) line.classList.add("boot-blink"); + bootContent.appendChild(line); + }); + currentMessageIndex = bootMessages.length; +} + +async function finishBoot() { + console.log("Finishing boot sequence..."); + + // complete any remaining progress bars + completeCurrentProgressBar(); + + // show remaining messages + currentMessageIndex++; + await showMessagesUpToStage("complete"); + await waitForContinue(); + + if (bootScreen) { + bootScreen.style.opacity = "0"; + bootScreen.style.transition = "opacity 0.5s ease"; + + setTimeout(() => { + if (bootScreen && bootScreen.parentNode) { + document.body.removeChild(bootScreen); + } + bootScreen = null; + + if (window.pyodide && window.pkg) { + try { + const auth_modal = window.pyodide.pyimport("auth_modal"); + auth_modal.show_auth_modal_after_boot(); + console.log("Auth modal initialized via Python"); + } catch (error) { + console.error("Error loading auth modal:", error); + // fallback: show interface directly + showMainInterface(); + } + } else { + console.log("Pyodide not ready, showing interface directly"); + showMainInterface(); + } + + }, 500); + } + + isBootComplete = true; + console.log("Boot sequence complete - authentication phase"); +} + +function showMainInterface() { + const mainInterface = document.querySelector(".interface"); + if (mainInterface) { + mainInterface.style.transition = "opacity 0.5s ease"; + mainInterface.style.opacity = "1"; + } + console.log("Main interface shown directly"); +} + +function waitForContinue() { + return new Promise((resolve) => { + const handleInteraction = (e) => { + e.preventDefault(); + e.stopPropagation(); + continuePressed = true; + + document.removeEventListener("keydown", handleInteraction, true); + document.removeEventListener("click", handleInteraction, true); + if (bootScreen) { + bootScreen.removeEventListener("click", handleInteraction, true); + } + + resolve(); + }; + + document.addEventListener("keydown", handleInteraction, true); + document.addEventListener("click", handleInteraction, true); + if (bootScreen) { + bootScreen.addEventListener("click", handleInteraction, true); + } + + // Auto-continue after 3 seconds + const timeoutId = setTimeout(() => { + if (!continuePressed) { + continuePressed = true; + document.removeEventListener("keydown", handleInteraction, true); + document.removeEventListener("click", handleInteraction, true); + if (bootScreen) { + bootScreen.removeEventListener("click", handleInteraction, true); + } + resolve(); + } + }, 3000); + + const originalResolve = resolve; + resolve = () => { + clearTimeout(timeoutId); + originalResolve(); + }; + }); +} + +// styles for boot screen (inject into document head) +const bootStyles = ` +.boot-screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #000; + color: #00ff00; + font-family: "JetBrains Mono", "Courier New", monospace; + z-index: 10000; + padding: 20px; + font-size: 14px; + line-height: 1.4; + overflow-y: auto; + cursor: pointer; +} + +.boot-content { + max-width: 800px; + margin: 0 auto; +} + +.boot-line { + opacity: 0; + margin-bottom: 2px; + transition: opacity 0.3s ease; +} + +.boot-line.boot-show { + opacity: 1; +} + +.boot-line.boot-blink { + animation: bootBlink 0.5s infinite; +} + +.boot-progress { + display: inline-block; + width: 200px; + height: 8px; + border: 1px solid #00ff00; + margin-left: 10px; + position: relative; + vertical-align: middle; +} + +.boot-progress-bar { + height: 100%; + background: #00ff00; + width: 0%; + transition: width 0.2s ease; +} + +@keyframes bootBlink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} +`; + +const styleSheet = document.createElement("style"); +styleSheet.textContent = bootStyles; +document.head.appendChild(styleSheet); diff --git a/iridescent-ivies/src/web/favicon.png b/iridescent-ivies/src/web/favicon.png new file mode 100644 index 00000000..a7162119 Binary files /dev/null and b/iridescent-ivies/src/web/favicon.png differ diff --git a/iridescent-ivies/src/web/styles.css b/iridescent-ivies/src/web/styles.css new file mode 100644 index 00000000..a2f3d5b4 --- /dev/null +++ b/iridescent-ivies/src/web/styles.css @@ -0,0 +1,1077 @@ +/* fonts */ +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap"); + +/* Thanks to this article on the CRT effect: https://aleclownes.com/2017/02/01/crt-display.html */ +/* Read for reference on the values used */ + +@keyframes flicker { + 0% { + opacity: 0.27861; + } + 5% { + opacity: 0.34769; + } + 10% { + opacity: 0.23604; + } + 15% { + opacity: 0.90626; + } + 20% { + opacity: 0.18128; + } + 25% { + opacity: 0.83891; + } + 30% { + opacity: 0.65583; + } + 35% { + opacity: 0.67807; + } + 40% { + opacity: 0.26559; + } + 45% { + opacity: 0.84693; + } + 50% { + opacity: 0.96019; + } + 55% { + opacity: 0.08594; + } + 60% { + opacity: 0.20313; + } + 65% { + opacity: 0.71988; + } + 70% { + opacity: 0.53455; + } + 75% { + opacity: 0.37288; + } + 80% { + opacity: 0.71428; + } + 85% { + opacity: 0.70419; + } + 90% { + opacity: 0.7003; + } + 95% { + opacity: 0.36108; + } + 100% { + opacity: 0.24387; + } +} + + +@keyframes textShadow { + 0% { + text-shadow: 0.4389924193300864px 0 1px rgba(0,30,255,0.5), -0.4389924193300864px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 5% { + text-shadow: 2.7928974010788217px 0 1px rgba(0,30,255,0.5), -2.7928974010788217px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 10% { + text-shadow: 0.02956275843481219px 0 1px rgba(0,30,255,0.5), -0.02956275843481219px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 15% { + text-shadow: 0.40218538552878136px 0 1px rgba(0,30,255,0.5), -0.40218538552878136px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 20% { + text-shadow: 3.4794037899852017px 0 1px rgba(0,30,255,0.5), -3.4794037899852017px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 25% { + text-shadow: 1.6125630401149584px 0 1px rgba(0,30,255,0.5), -1.6125630401149584px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 30% { + text-shadow: 0.7015590085143956px 0 1px rgba(0,30,255,0.5), -0.7015590085143956px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 35% { + text-shadow: 3.896914047650351px 0 1px rgba(0,30,255,0.5), -3.896914047650351px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 40% { + text-shadow: 3.870905614848819px 0 1px rgba(0,30,255,0.5), -3.870905614848819px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 45% { + text-shadow: 2.231056963361899px 0 1px rgba(0,30,255,0.5), -2.231056963361899px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 50% { + text-shadow: 0.08084290417898504px 0 1px rgba(0,30,255,0.5), -0.08084290417898504px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 55% { + text-shadow: 2.3758461067427543px 0 1px rgba(0,30,255,0.5), -2.3758461067427543px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 60% { + text-shadow: 2.202193051050636px 0 1px rgba(0,30,255,0.5), -2.202193051050636px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 65% { + text-shadow: 2.8638780614874975px 0 1px rgba(0,30,255,0.5), -2.8638780614874975px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 70% { + text-shadow: 0.48874025155497314px 0 1px rgba(0,30,255,0.5), -0.48874025155497314px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 75% { + text-shadow: 1.8948491305757957px 0 1px rgba(0,30,255,0.5), -1.8948491305757957px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 80% { + text-shadow: 0.0833037308038857px 0 1px rgba(0,30,255,0.5), -0.0833037308038857px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 85% { + text-shadow: 0.09769827255241735px 0 1px rgba(0,30,255,0.5), -0.09769827255241735px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 90% { + text-shadow: 3.443339761481782px 0 1px rgba(0,30,255,0.5), -3.443339761481782px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 95% { + text-shadow: 2.1841838852799786px 0 1px rgba(0,30,255,0.5), -2.1841838852799786px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } + 100% { + text-shadow: 2.6208764473832513px 0 1px rgba(0,30,255,0.5), -2.6208764473832513px 0 1px rgba(255,0,80,0.3), 0 0 3px; + } +} +.crt { + animation: textShadow 1.6s infinite; +} + +.crt::before { + content: " "; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); + z-index: 2; + background-size: 100% 2px, 3px 100%; + pointer-events: none; +} +.crt::after { + content: " "; + display: block; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: rgba(18, 16, 16, 0.1); + opacity: 0; + z-index: 2; + pointer-events: none; + animation: flicker 0.15s infinite; +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +/* base layout */ +body { + font-family: "JetBrains Mono", "Courier New", monospace; + background: #000; + color: #00ff00; + height: 100vh; + overflow: hidden; +} + +.screen { + width: 100vw; + height: 100vh; + background: radial-gradient(ellipse at center, #001a00 0%, #000000 70%); + position: relative; + overflow: hidden; + box-shadow: inset 0 0 100px rgba(0, 255, 0, 0.1); +} + +/* crt effect */ +.screen::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 255, 0, 0.02) 2px, + rgba(0, 255, 0, 0.02) 4px + ); + pointer-events: none; + z-index: 10; +} + +.screen::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient( + ellipse at center, + transparent 60%, + rgba(0, 0, 0, 0.4) 100% + ); + pointer-events: none; + z-index: 5; +} + +/* electric wave */ +.electric-wave { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 0deg, + transparent 0%, + rgba(0, 255, 0, 0.1) 49%, + rgba(0, 255, 0, 0.3) 50%, + rgba(0, 255, 0, 0.1) 51%, + transparent 100% + ); + transform: translateY(-100%); + pointer-events: none; + z-index: 15; + opacity: 0; +} + +.electric-wave.active { + animation: electricWave 2s ease-out; +} + +@keyframes electricWave { + 0% { + transform: translateY(-100%); + opacity: 0; + } + 10% { + opacity: 1; + } + 90% { + opacity: 1; + } + 100% { + transform: translateY(100vh); + opacity: 0; + } +} + +/* main interface */ +.interface { + position: relative; + z-index: 1; + height: 100%; + padding: 30px; + display: flex; + flex-direction: column; +} + +.title-bar { + background: #00ff00; + color: #000; + padding: 8px 16px; + font-weight: bold; + font-size: 16px; + margin-bottom: 2px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.window { + border: 2px solid #00ff00; + background: #000; + flex: 1; + display: flex; + flex-direction: column; +} + +.content-area { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* panels */ +.query-panel, +.results-panel { + border: 1px solid #00ff00; + background: #001100; +} + +.panel-header { + background: #003300; + padding: 4px 8px; + font-size: 14px; + font-weight: bold; + border-bottom: 1px solid #00ff00; +} + +.panel-content { + padding: 12px; +} + +/* query input */ +.sql-input { + width: 100%; + height: 100px; + background: #000; + border: 1px inset #00ff00; + color: #00ff00; + font-family: inherit; + font-size: 15px; + padding: 8px; + resize: none; + outline: none; +} + +.sql-input:focus { + background: #001100; + box-shadow: inset 0 0 5px rgba(0, 255, 0, 0.3); +} + +.sql-input::placeholder { + color: #006600; + opacity: 0.7; +} + +/* buttons */ +.button-row { + margin-top: 8px; + display: flex; + gap: 8px; +} + +.btn { + background: #003300; + border: 2px outset #00ff00; + color: #00ff00; + padding: 8px 20px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.2s ease; +} + +.btn:hover { + background: #004400; +} + +.btn:active { + border: 2px inset #00ff00; + background: #002200; +} + +.btn:disabled { + background: #001100; + color: #004400; + cursor: not-allowed; + border-color: #004400; +} + +.btn-danger { + background: #330000; + border-color: #ff0000; + color: #ff0000; +} + +.btn-danger:hover { + background: #440000; +} + +.btn-danger:active { + background: #220000; +} + +/* results table */ +.results-panel { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.table-area { + flex: 1; + overflow: auto; + background: #000; + border: 1px inset #00ff00; + margin: 12px; + max-height: calc(100vh - 400px); +} + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + min-width: 800px; +} + +.data-table th { + background: #003300; + border: 1px solid #00ff00; + padding: 8px 15px; + text-align: left; + font-weight: bold; + position: sticky; + top: 0; + z-index: 2; +} + +.data-table td { + border: 1px solid #004400; + padding: 8px 15px; + white-space: nowrap; +} + +.data-table tr:nth-child(even) { + background: #001100; +} + +.data-table tr:hover { + background: #002200; +} + +/* empty state */ +.data-table .empty-state { + text-align: center; + padding: 40px 20px; + color: #666; + font-style: italic; +} + +/* status line */ +.status-line { + background: #003300; + border-top: 1px solid #00ff00; + padding: 4px 12px; + font-size: 11px; + display: flex; + justify-content: space-between; + flex-shrink: 0; +} + +/* status message colors */ +.status-success { + color: #00ff00; +} + +.status-error { + color: #ff0000; +} + +.status-warning { + color: #ffff00; +} + +.status-info { + color: #00ffff; +} + +/* loading overlay */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + display: none; + align-items: center; + justify-content: center; +} + +.loading-overlay.show { + display: flex; +} + +.loading-content { + text-align: center; + color: #00ff00; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #003300; + border-top: 3px solid #00ff00; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px; +} + +.loading-text { + font-size: 18px; + font-weight: bold; + letter-spacing: 2px; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* scrollbars */ +.table-area::-webkit-scrollbar { + width: 16px; + height: 16px; +} + +.table-area::-webkit-scrollbar-track { + background: #003300; + border: 1px solid #00ff00; +} + +.table-area::-webkit-scrollbar-thumb { + background: #00ff00; + border: 1px solid #003300; +} + +.table-area::-webkit-scrollbar-corner { + background: #003300; +} + +/* responsive (it works :D!!) */ +@media (max-width: 768px) { + .interface { + padding: 10px; + } + + .button-row { + flex-direction: column; + } + + .btn { + width: 100%; + } + + .title-bar { + font-size: 14px; + padding: 6px 12px; + } + + .title-bar span:last-child { + display: none; + } + + .sql-input { + height: 80px; + font-size: 13px; + } + + .data-table { + font-size: 12px; + min-width: 600px; + } + + .data-table th, + .data-table td { + padding: 6px 10px; + } +} + +@media (max-width: 480px) { + .interface { + padding: 5px; + } + + .content-area { + padding: 8px; + gap: 8px; + } + + .panel-content { + padding: 8px; + } + + .sql-input { + height: 60px; + font-size: 12px; + } +} + +#image-modal { + position: fixed; + top: 80px; + left: 50%; + height: 85vh; + width: 50%; + transform: translateX(-50%); + z-index: 2; + display: none; + text-align: center; + overflow: scroll; + -ms-overflow-style: none; + scrollbar-width: none; +} + +#image-modal-container { + max-width: 100%; +} + +#image-modal-close { + cursor: pointer; + float: right; +} + +#ascii-display { + width: 100ch; + font-size: 8px; + margin: 0 auto 20px auto; + text-align: center; + display: block; +} + +#image-alt-text { + display: block; + margin-top: 15px; + margin-bottom: 20px; + color: #00ff00; + font-size: 18px; + padding: 0 10px; +} + +.modal-footer { + margin-top: auto; + text-align: center; + padding: 10px; + border-top: 1px solid #004400; +} + +#image-modal-full-link { + font-size: 10px; + color: #66ff66; + text-decoration: none; + padding: 4px 8px; + border: 1px solid #004400; + background: #001100; + transition: all 0.2s ease; +} + +#image-modal-full-link:hover { + background: #002200; + border-color: #00ff00; + color: #00ff00; +} + +#image-modal::-webkit-scrollbar { + display: none; +} + +.auth-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.98); + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + font-family: "JetBrains Mono", "Courier New", monospace; + color: #00ff00; + opacity: 0; + transition: opacity 0.5s ease; + display: none; +} + +.auth-modal.show { + opacity: 1; + display: flex; +} + +.auth-modal::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(0, 255, 0, 0.02) 2px, + rgba(0, 255, 0, 0.02) 4px + ); + pointer-events: none; + z-index: 10; +} + +.modal-container { + background: radial-gradient(ellipse at center, #001a00 0%, #000000 70%); + border: 2px solid #00ff00; + max-width: 500px; + width: 90%; + position: relative; + box-shadow: inset 0 0 100px rgba(0, 255, 0, 0.1); +} + +.modal-container { + animation: textShadow 1.6s infinite; +} + +.modal-header { + background: #00ff00; + color: #000; + padding: 8px 16px; + font-weight: bold; + font-size: 16px; + margin-bottom: 2px; + text-align: center; + text-transform: uppercase; + letter-spacing: 2px; +} + +.modal-body { + border: 1px solid #00ff00; + background: #001100; + padding: 16px; +} + +.auth-title { + text-align: center; + font-size: 18px; + font-weight: bold; + margin-bottom: 8px; + color: #00ff00; +} + +.auth-subtitle { + text-align: center; + font-size: 12px; + color: #66ff66; + margin-bottom: 20px; + opacity: 0.8; +} + +.form-group { + margin-bottom: 12px; +} + +.form-label { + display: block; + margin-bottom: 4px; + font-size: 14px; + font-weight: bold; + color: #00ff00; + text-transform: uppercase; +} + +.form-input { + width: 100%; + /* same effect: .sql-input */ + background: #000; + border: 1px inset #00ff00; + color: #00ff00; + font-family: inherit; + font-size: 14px; + padding: 8px; + outline: none; +} + +.form-input:focus { + /* same effect: .sql-input:focus */ + background: #001100; + box-shadow: inset 0 0 5px rgba(0, 255, 0, 0.3); +} + +.form-input::placeholder { + color: #006600; + opacity: 0.7; +} + +.button-container { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.modal-btn { + flex: 1; + background: #003300; + border: 2px outset #00ff00; + color: #00ff00; + padding: 8px 20px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + font-weight: bold; + transition: background-color 0.2s ease; + text-transform: uppercase; +} + +.modal-btn:hover { + background: #004400; +} + +.modal-btn:active { + border: 2px inset #00ff00; + background: #002200; +} + +.modal-btn:disabled { + background: #001100; + color: #004400; + cursor: not-allowed; + border-color: #004400; +} + +.btn-primary { + background: #004400; +} + +.btn-secondary { + background: #333300; + border-color: #ffff00; + color: #ffff00; +} + +.btn-secondary:hover { + background: #444400; +} + +.btn-secondary:active { + background: #222200; +} + +/* CRT Toggle Button Styles */ +.crt-toggle-container { + margin: 12px 0 8px 0; + display: flex; + justify-content: center; +} + +.btn-crt { + background: #003300; + border: 2px outset #00ff00; + color: #00ff00; + padding: 8px 20px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + font-weight: bold; + transition: all 0.2s ease; + text-transform: uppercase; + letter-spacing: 1px; + min-width: 150px; + transform: scale(1.0); +} + +.btn-crt:hover { + background: #004400; + box-shadow: 0 0 8px rgba(0, 255, 0, 0.3); +} + +.btn-crt:active { + border: 2px inset #00ff00; + background: #002200; + transform: scale(0.98); +} + +/* CRT effect disabled state styles */ +.btn-crt.crt-disabled { + background: #333300; + border-color: #ffff00; + color: #ffff00; +} + +.btn-crt.crt-disabled:hover { + background: #444400; + box-shadow: 0 0 8px rgba(255, 255, 0, 0.3); +} + +.security-notice { + background: #330000; + border: 1px solid #ff6600; + color: #ff9999; + padding: 8px; + margin-top: 12px; + font-size: 10px; +} + +.security-notice::before { + content: "⚠ "; + color: #ff6600; + font-weight: bold; +} + +/* loading points effect */ +.loading-dots::after { + content: ""; + animation: loadingDots 1.5s infinite; +} + +@keyframes loadingDots { + 0%, 20% { content: ""; } + 40% { content: "."; } + 60% { content: ".."; } + 80%, 100% { content: "..."; } +} + +/* responsive */ +@media (max-width: 768px) { + .modal-container { + width: 95%; + } + + .modal-body { + padding: 12px; + } + + .button-container { + flex-direction: column; + } + + .modal-btn { + width: 100%; + } + + .btn-crt { + width: 100%; + margin: 8px 0; + } + + .modal-header { + font-size: 14px; + padding: 6px 12px; + } + + .form-input { + font-size: 13px; + } +} + +@media (max-width: 480px) { + .modal-body { + padding: 8px; + } + + .form-input { + font-size: 12px; + } + + .btn-crt { + font-size: 14px; + padding: 8px 20px; + } +} + +/* BSOD Styles */ +.bsod-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: #0000aa; + color: white; + font-family: Consolas, "Lucida Console", "Courier New", monospace; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + animation: bsodFlicker 0.1s ease-in; +} + +.bsod-content { + max-width: 800px; + padding: 40px; + line-height: 1.4; + font-size: 13px; + font-weight: normal; +} + +.bsod-header { + font-size: 14px; + margin-bottom: 20px; + font-weight: normal; +} + +.bsod-error { + font-size: 16px; + font-weight: bold; + margin: 20px 0; + color: #ffffff; + text-decoration: underline; +} + +.bsod-text { + margin: 20px 0; + line-height: 1.5; + font-size: 13px; +} + +.bsod-technical { + margin-top: 30px; + font-family: Consolas, "Lucida Console", "Courier New", monospace; + font-size: 11px; + color: #cccccc; + line-height: 1.3; +} + +@keyframes bsodFlicker { + 0% { opacity: 0; } + 10% { opacity: 1; } + 15% { opacity: 0; } + 20% { opacity: 1; } + 25% { opacity: 0; } + 30% { opacity: 1; } + 100% { opacity: 1; } +} + +/* Responsive BSOD */ +@media (max-width: 768px) { + .bsod-content { + padding: 20px; + font-size: 11px; + } + + .bsod-header { + font-size: 12px; + } + + .bsod-error { + font-size: 14px; + } + + .bsod-technical { + font-size: 9px; + } +} + +@media (max-width: 480px) { + .bsod-content { + padding: 15px; + font-size: 10px; + } + + .bsod-header { + font-size: 11px; + } + + .bsod-error { + font-size: 12px; + } +}