diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..894571b --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/.gitignore b/.gitignore index cb81a46..bc9dc20 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,22 @@ reportlog.json .ruff_cache/ .pdm.toml requirements.txt +src/huesoporro/tts_files/ +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 77b20e7..e8b115c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +files: src|tests +exclude: ^$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 @@ -16,21 +18,40 @@ repos: - id: mixed-line-ending args: [ --fix=lf ] - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.6.4 - hooks: - - id: ruff - args: - - --fix - - --exit-non-zero-on-fix - - id: ruff-format - - repo: local hooks: - id: mypy name: mypy - entry: uv run mypy + entry: uv run mypy --check-untyped-defs language: system types: [ python ] + exclude: LICENSE|helm + exclude_types: + - markdown + - css + - html + + - id: ruff-format + name: ruff format + language: system + entry: ruff format . + exclude: LICENSE|charts + exclude_types: + - markdown + - css + - html + - javascript + + - id: ruff-check + name: ruff check + language: system + entry: ruff check . --fix --exit-non-zero-on-fix + exclude: LICENSE|charts + exclude_types: + - markdown + - css + - html + - javascript + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..88743df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# hadolint ignore=DL3006,DL3007 +FROM cgr.dev/chainguard/wolfi-base:latest AS base + +SHELL ["/bin/ash", "-ex", "-c"] + +ARG USERID=1000 +ARG GROUPID=1000 + +ENV USERNAME="huesoporro" +ENV APP_HOME="/home/$USERNAME" +ENV APP_PATH="$APP_HOME" + +ENV POETRY_VERSION=1.8.3 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONPATH="$APP_PATH" +ENV PATH="$APP_HOME/.local/bin:$PATH" + +# hadolint ignore=DL3001,DL3008,DL3018 +RUN apk add --no-cache make python3~=3.12 \ + && adduser -S -u "$USERID" -h "$APP_HOME" "$USERNAME" \ + && mkdir -p "$APP_PATH" \ + && chown -R "$USERID:$GROUPID" "$APP_PATH" + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +USER "$USERNAME" + +WORKDIR "$APP_PATH" + +COPY --chown=$USERNAME pyproject.toml uv.lock Makefile README.md ./ + +RUN uv sync + +COPY --chown=$USERNAME src/ src/ + + +FROM base AS serve + +CMD ["make", "serve"] \ No newline at end of file diff --git a/Makefile b/Makefile index 7b1af53..8f3b092 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ +PROJECT_NAME := "huesoporro" +PROJECT_TAG := "latest" +PROJECT_TARGET := "serve" + fmt: uvx pre-commit run --all-files --color always - .PHONY: tests tests: uv run pytest --cov=halig -vv tests --report-log reportlog.json uv run coverage html uv run coverage xml + +serve: + uv run python -m src.huesoporro.main + +build: + docker build . -t git.roboces.dev/catalin/$(PROJECT_NAME):$(PROJECT_TAG) --target $(PROJECT_TARGET) diff --git a/README.md b/README.md index e69de29..22880de 100644 --- a/README.md +++ b/README.md @@ -0,0 +1 @@ +# huesoporro diff --git a/charts/huesoporro/.helmignore b/charts/huesoporro/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/charts/huesoporro/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/huesoporro/Chart.yaml b/charts/huesoporro/Chart.yaml new file mode 100644 index 0000000..232c359 --- /dev/null +++ b/charts/huesoporro/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: huesoporro +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.2.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "0.2.0" diff --git a/charts/huesoporro/templates/NOTES.txt b/charts/huesoporro/templates/NOTES.txt new file mode 100644 index 0000000..62f1b42 --- /dev/null +++ b/charts/huesoporro/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "helm.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "helm.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "helm.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "helm.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/charts/huesoporro/templates/_helpers.tpl b/charts/huesoporro/templates/_helpers.tpl new file mode 100644 index 0000000..ba04c30 --- /dev/null +++ b/charts/huesoporro/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "helm.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "helm.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "helm.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "helm.labels" -}} +helm.sh/chart: {{ include "helm.chart" . }} +{{ include "helm.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "helm.selectorLabels" -}} +app.kubernetes.io/name: {{ include "helm.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "helm.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "helm.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/charts/huesoporro/templates/deployment.yaml b/charts/huesoporro/templates/deployment.yaml new file mode 100644 index 0000000..0a9fb22 --- /dev/null +++ b/charts/huesoporro/templates/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "helm.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "helm.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helm.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- if and .Values.persistence.enabled .Values.persistence.volumeOwner.enabled }} + initContainers: + - name: volume-permissions + image: busybox + command: [ 'sh', '-c', 'chown -R {{ .Values.persistence.volumeOwner.uid }}:{{ .Values.persistence.volumeOwner.gid }} /data' ] + volumeMounts: + - name: data + mountPath: /data + securityContext: + runAsUser: 0 + {{- end }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- if .Values.persistence.enabled }} + volumeMounts: + - name: data + mountPath: /home/huesoporro/.local/share/huesoporro + {{- end }} + securityContext: + runAsUser: {{ .Values.persistence.volumeOwner.uid }} + runAsGroup: {{ .Values.persistence.volumeOwner.gid }} + envFrom: + - secretRef: + name: {{ .Values.secret.existingSecretName }} + + {{- if .Values.persistence.enabled }} + volumes: + - name: data + persistentVolumeClaim: + claimName: {{ include "helm.fullname" . }}-data + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/huesoporro/templates/hpa.yaml b/charts/huesoporro/templates/hpa.yaml new file mode 100644 index 0000000..28c087e --- /dev/null +++ b/charts/huesoporro/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "helm.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/huesoporro/templates/ingress.yaml b/charts/huesoporro/templates/ingress.yaml new file mode 100644 index 0000000..5bdb791 --- /dev/null +++ b/charts/huesoporro/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- with .pathType }} + pathType: {{ . }} + {{- end }} + backend: + service: + name: {{ include "helm.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/huesoporro/templates/pvc.yaml b/charts/huesoporro/templates/pvc.yaml new file mode 100644 index 0000000..48ee097 --- /dev/null +++ b/charts/huesoporro/templates/pvc.yaml @@ -0,0 +1,15 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "helm.fullname" . }}-data + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.size }} + storageClassName: {{ .Values.persistence.storageClassName }} +{{- end }} \ No newline at end of file diff --git a/charts/huesoporro/templates/service.yaml b/charts/huesoporro/templates/service.yaml new file mode 100644 index 0000000..de450fc --- /dev/null +++ b/charts/huesoporro/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "helm.selectorLabels" . | nindent 4 }} diff --git a/charts/huesoporro/templates/serviceaccount.yaml b/charts/huesoporro/templates/serviceaccount.yaml new file mode 100644 index 0000000..d470465 --- /dev/null +++ b/charts/huesoporro/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "helm.serviceAccountName" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/charts/huesoporro/templates/tests/test-connection.yaml b/charts/huesoporro/templates/tests/test-connection.yaml new file mode 100644 index 0000000..bf1c65f --- /dev/null +++ b/charts/huesoporro/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "helm.fullname" . }}-test-connection" + labels: + {{- include "helm.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "helm.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/charts/huesoporro/values.yaml b/charts/huesoporro/values.yaml new file mode 100644 index 0000000..324a8d5 --- /dev/null +++ b/charts/huesoporro/values.yaml @@ -0,0 +1,138 @@ +# Default values for helm. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + repository: git.roboces.dev/catalin/huesoporro + # This sets the pull policy for images. + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "0.2.0" + +# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +#This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: LoadBalancer + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 8000 + +# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: /healthz + port: http +readinessProbe: + httpGet: + path: /healthz + port: http + +#This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +persistence: + enabled: false + accessModes: + - ReadWriteMany + size: 10Gi + storageClassName: "default" + volumeOwner: + enabled: true + uid: 1000 + gid: 1000 + annotations: {} + +secret: + existingSecretName: huesoporro-secrets \ No newline at end of file diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..8ab1706 --- /dev/null +++ b/devenv.lock @@ -0,0 +1,116 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1733788855, + "owner": "cachix", + "repo": "devenv", + "rev": "d59fee8696cd48f69cf79f65992269df9891ba86", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1733328505, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1733477122, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1733730953, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7109b680d161993918b0a126f38bc39763e5a709", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1733665616, + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "d8c02f0ffef0ef39f6063731fc539d8c71eb463a", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..771f4d7 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,22 @@ +{ pkgs, lib, config, inputs, ... }: + +{ + env.GREET = "devenv"; + + packages = [ pkgs.git ]; + + languages.python.enable = true; + languages.python.uv.enable = true; + + scripts.hello.exec = '' + echo hello from $GREET + ''; + + enterShell = '' + hello + git --version + fish + ''; + + dotenv.enable = true; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..116a2ad --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allowUnfree to true. +# allowUnfree: true + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/markovbot.spec b/markovbot.spec deleted file mode 100644 index dd62d9e..0000000 --- a/markovbot.spec +++ /dev/null @@ -1,38 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -from kivy_deps import sdl2, glew - -a = Analysis( - ['src\\markovbot_gui\\main.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.datas, - *[Tree(p) for p in (sdl2.dep_bins + glew.dep_bins)], - name='markovbot', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) diff --git a/pyproject.toml b/pyproject.toml index 207f2de..62df643 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,50 +1,41 @@ [project] -name = "markovbot-gui" -version = "0.1.2" -description = "Markov Chain Bot GUI" +name = "huesoporro" +version = "0.2.0" +description = "Misc Twitch bots" readme = "README.md" authors = [ - { name = "tomaarsen" }, { name = "185504a9", email = "catalin@roboces.dev" } ] requires-python = ">=3.11" dependencies = [ - "kivy[base]>=2.3.0", "nltk>=3.9.1", - "pillow>=10.4.0", "platformdirs>=4.3.6", "pydantic>=2.9.2", "pydantic-settings>=2.6.0", "pyinstaller>=6.11.0", "twitchwebsocket>=1.2.1", "loguru>=0.7.2", + "ffmpeg>=1.4", + "ffmpeg-python>=0.2.0", + "gtts>=2.5.4", + "litestar[standard]>=2.13.0", + "httpx>=0.28.0", ] [tool.uv] dev-dependencies = [ "mypy>=1.13.0", - "pyright>=1.1.387", - "ruff>=0.7.0", ] [[tool.mypy.overrides]] module = [ - "kivy", - "kivy.uix.widget", - "kivy.uix.popup", - "kivy.uix.button", - "kivy.uix.boxlayout", - "kivy.uix.textinput", - "kivy.uix.label", - "kivy.metrics", - "kivy.app", - "kivy.clock", "nltk", "nltk.tokenize", "nltk.tokenize.treebank", "nltk.tokenize.destructive", "TwitchWebsocket", - "tokenizer" + "tokenizer", + "gtts" ] ignore_missing_imports = true diff --git a/src/markovbot_gui/__init__.py b/src/huesoporro/__init__.py similarity index 100% rename from src/markovbot_gui/__init__.py rename to src/huesoporro/__init__.py diff --git a/src/huesoporro/chatbot.py b/src/huesoporro/chatbot.py new file mode 100644 index 0000000..30f04f8 --- /dev/null +++ b/src/huesoporro/chatbot.py @@ -0,0 +1,62 @@ +import asyncio +from asyncio import sleep as asleep +from queue import Queue +from time import sleep + +import nltk +from litestar import WebSocket +from loguru import logger + +from src.huesoporro.libs.markov_chain_bot import MarkovChain +from src.huesoporro.libs.settings import Settings as MarkovChainSettings +from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage + +nltk.download("punkt_tab") + + +class ChatbotManager: + def __init__(self): + self.bot: MarkovChain | None = None + self.clients: set[WebSocket] = set() + self.log_queue: Queue = Queue() + self.tasks: set = set() + + def start_bot( + self, + channel_name: str, + nickname: str, + authentication: str, + ): + task = asyncio.create_task(self.send_bot_status()) + self.tasks.add(task) + if self.bot: + return + self.bot = MarkovChain( + settings=MarkovChainSettings( + Channel=channel_name, + Nickname=nickname, + Authentication=authentication, + AutomaticGenerationTimer=300, + ), + ) + + self.bot.run_bot() + sleep(2) + + def stop_bot(self): + self.bot.stop_bot() + self.bot = None + + async def send_bot_status(self): + while True: + for client in self.clients: + message = WebsocketMessage( + command=WebsocketCommands.CHATBOT_STATUS, + data={"status": "ok" if self.bot else "ko"}, + ) + await client.send_text(message.model_dump_json()) + logger.info( + f"Sending bot status {message} to {client.client.host}:{client.client.port}" + ) + + await asleep(2) diff --git a/src/markovbot_gui/libs/LICENSE b/src/huesoporro/libs/LICENSE similarity index 100% rename from src/markovbot_gui/libs/LICENSE rename to src/huesoporro/libs/LICENSE diff --git a/src/markovbot_gui/libs/README.md b/src/huesoporro/libs/README.md similarity index 100% rename from src/markovbot_gui/libs/README.md rename to src/huesoporro/libs/README.md diff --git a/src/markovbot_gui/libs/__init__.py b/src/huesoporro/libs/__init__.py similarity index 100% rename from src/markovbot_gui/libs/__init__.py rename to src/huesoporro/libs/__init__.py diff --git a/src/markovbot_gui/libs/db.py b/src/huesoporro/libs/db.py similarity index 99% rename from src/markovbot_gui/libs/db.py rename to src/huesoporro/libs/db.py index f547a81..b1b5c16 100644 --- a/src/markovbot_gui/libs/db.py +++ b/src/huesoporro/libs/db.py @@ -88,7 +88,7 @@ class Database: def __init__(self, channel: str): self.user_data_path = platformdirs.user_data_path( - "markovbot_gui", + "huesoporro", ensure_exists=True, ) self.db_path = ( @@ -191,10 +191,11 @@ class Database: fetch=True, ): logger.info("Creating backup before updating Database...") + # Connect to both the new and backup, backup, and close both def progress(status, remaining, total): - logging.debug(f"Copied {total-remaining} of {total} pages...") + logging.debug(f"Copied {total - remaining} of {total} pages...") conn = sqlite3.connect(f"MarkovChain_{channel.replace('#', '').lower()}.db") back_conn = sqlite3.connect( @@ -356,7 +357,7 @@ class Database: from nltk import ngrams - from src.markovbot_gui.libs.tokenizer import tokenize + from src.huesoporro.libs.tokenizer import tokenize channel = channel.replace("#", "").lower() copyfile( diff --git a/src/markovbot_gui/libs/markov_chain_bot.py b/src/huesoporro/libs/markov_chain_bot.py similarity index 94% rename from src/markovbot_gui/libs/markov_chain_bot.py rename to src/huesoporro/libs/markov_chain_bot.py index 2e661b5..68b67a8 100644 --- a/src/markovbot_gui/libs/markov_chain_bot.py +++ b/src/huesoporro/libs/markov_chain_bot.py @@ -6,10 +6,10 @@ from loguru import logger from nltk.tokenize import sent_tokenize from TwitchWebsocket import Message, TwitchWebsocket -from src.markovbot_gui.libs.db import Database -from src.markovbot_gui.libs.settings import Settings -from src.markovbot_gui.libs.timer import LoopingTimer -from src.markovbot_gui.libs.tokenizer import detokenize, tokenize +from src.huesoporro.libs.db import Database +from src.huesoporro.libs.settings import Settings +from src.huesoporro.libs.timer import LoopingTimer +from src.huesoporro.libs.tokenizer import detokenize, tokenize class Commands(StrEnum): @@ -68,11 +68,12 @@ class MarkovChain: ) def run_bot(self): - self.ws.start_bot() + self.ws.start_nonblocking() def stop_bot(self): self.ws.leave_channel(self.s.channel_name) self.ws.stop() + logger.info("Stopped bot") def _command_help(self) -> None: """Send a Help message to the connected chat, as long as the bot wasn't disabled.""" @@ -249,6 +250,28 @@ class MarkovChain: def message_handler(self, message: Message): # noqa: C901, PLR0911, PLR0912 try: + """ + tts_message = { + "badge-info": "subscriber/4", + "badges": "vip/1,subscriber/3,sub-gifter/5", + "color": "#F79AC6", + "custom-reward-id": "8c454446-73b0-480f-946e-d6b5f5c5e331", + "display-name": "robosap1ens__", + "emotes": "", + "first-msg": "0", + "flags": "", + "id": "6cbd37eb-49ae-41f5-b073-345275c91a07", + "mod": "0", + "returning-chatter": "0", + "room-id": "600944302", + "subscriber": "1", + "tmi-sent-ts": "1733252657689", + "turbo": "0", + "user-id": "713968248", + "user-type": "", + "vip": "1", + } + """ if not message.user or message.user in self.s.denied_users: logger.debug(f"User {message.user} can't send messages") return diff --git a/src/markovbot_gui/libs/settings.py b/src/huesoporro/libs/settings.py similarity index 100% rename from src/markovbot_gui/libs/settings.py rename to src/huesoporro/libs/settings.py diff --git a/src/markovbot_gui/libs/timer.py b/src/huesoporro/libs/timer.py similarity index 100% rename from src/markovbot_gui/libs/timer.py rename to src/huesoporro/libs/timer.py diff --git a/src/markovbot_gui/libs/tokenizer.py b/src/huesoporro/libs/tokenizer.py similarity index 100% rename from src/markovbot_gui/libs/tokenizer.py rename to src/huesoporro/libs/tokenizer.py diff --git a/src/huesoporro/main.py b/src/huesoporro/main.py new file mode 100644 index 0000000..d8169c7 --- /dev/null +++ b/src/huesoporro/main.py @@ -0,0 +1,224 @@ +import json +import secrets +from json import JSONDecodeError + +import httpx +import uvicorn +from litestar import Litestar, MediaType, Request, Response, WebSocket, get +from litestar.connection import ASGIConnection +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.datastructures.state import State +from litestar.di import Provide +from litestar.exceptions import HTTPException +from litestar.handlers import BaseRouteHandler, WebsocketListener +from litestar.response import Redirect, Template +from litestar.static_files import StaticFilesConfig +from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR +from litestar.template import TemplateConfig +from loguru import logger + +from src.huesoporro.chatbot import ChatbotManager +from src.huesoporro.settings import Settings +from src.huesoporro.tts import TTSManager +from src.huesoporro.value_objects import WebsocketCommands, WebsocketMessage + + +async def _authenticate(access_token: str): + s = Settings.get() + client = httpx.AsyncClient( + base_url="https://id.twitch.tv", + ) + + resp = await client.get( + "/oauth2/validate", headers={"Authorization": f"OAuth {access_token}"} + ) + user_data = resp.json() + + if user_data.get("status"): + raise HTTPException(status_code=401, detail="Unauthorized") + + if (user := user_data["login"]) not in s.allowed_users: + raise HTTPException(status_code=403, detail="Forbidden") + + return user + + +async def authenticate( + connection: ASGIConnection, route_handler: BaseRouteHandler +) -> None: + """Extract cookie from connection and try to authenticate""" + + try: + login_data = json.loads(connection.cookies.get("twitchLoginData")) + except (JSONDecodeError, TypeError) as exc: + logger.warning(f"Error parsing twitch login data: {exc}") + raise HTTPException(status_code=401, detail="Unauthorized") from exc + + access_token = login_data.get("access_token") + if not login_data or not access_token: + raise HTTPException(status_code=401, detail="Unauthorized") + + user = await _authenticate(access_token) + + connection.state["user"] = user + connection.state["access_token"] = access_token + + +class WebsocketHandler(WebsocketListener): + path = "/ws" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.tts_manager = TTSManager() + self.chatbot_manager = ChatbotManager() + self.user = None + self.access_token = None + + async def on_accept(self, socket: WebSocket, state: State) -> None: + """If the authentication is correct, add the manager's clients list""" + + cookies = socket.cookies.get("twitchLoginData") + try: + access_token = json.loads(cookies).get("access_token") + except (JSONDecodeError, TypeError) as exc: + logger.warning(f"Error parsing twitch login data {exc}") + return + if not access_token: + return + user = await _authenticate(access_token) + + self.user = user + self.access_token = access_token + self.chatbot_manager.clients.add(socket) + self.tts_manager.clients.append(socket) + + logger.info( + f"Connection accepted from {socket.client.host}:{socket.client.port}" # type: ignore[union-attr] + ) + + async def on_disconnect(self, socket: WebSocket) -> None: + # Remove client from the list + if socket in self.tts_manager.clients: + self.tts_manager.clients.remove(socket) + self.chatbot_manager.clients.remove(socket) + logger.info(f"Connection closed by {socket.client.host}:{socket.client.port}") # type: ignore[union-attr] + + async def on_receive(self, data: str, state: State) -> None: + message = WebsocketMessage(**json.loads(data)) + logger.info(f"Received {message.command.value} command") + + match message.command: + case WebsocketCommands.TTS_SEND: + await self.tts_manager.add_to_queue(**message.data) + case WebsocketCommands.CHATBOT_START: + self.chatbot_manager.start_bot( + **message.data + | { + "nickname": self.user, + "authentication": f"oauth:{self.access_token}", + }, + ) + case WebsocketCommands.CHATBOT_STOP: + self.chatbot_manager.stop_bot() + + +@get( + "/tts", + media_type=MediaType.HTML, + guards=[authenticate], +) +async def get_tts_overlay() -> Template: + return Template(template_name="tts.html") + + +@get( + "/", + media_type=MediaType.HTML, + guards=[authenticate], +) +async def get_index() -> Template: + return Template( + template_name="index.html", + ) + + +@get("/login", media_type=MediaType.HTML, dependencies={"s": Provide(Settings.get)}) +async def login(s: Settings) -> Template: + scopes = "+".join(s.twitch_scopes) + return Template( + "login.html", + context={ + "twitch_login_url": "https://id.twitch.tv/oauth2/authorize?response_type=token" + f"&client_id={s.twitch_client_id}" + f"&redirect_uri={s.server_hostname}login" + f"&scope={scopes}" + f"&state={secrets.token_urlsafe(32)}" + }, + ) + + +@get("/healthz") +def get_health() -> dict: + return {"status": "ok"} + + +def exception_handler(_: Request, exc: Exception) -> Response: + status_code = getattr(exc, "status_code", HTTP_500_INTERNAL_SERVER_ERROR) + detail = getattr(exc, "detail", "") + + if isinstance(exc, HTTPException) and (exc.status_code in [401, 403]): + logger.warning("User could not authenticate. Redirecting to /login page") + return Redirect("/login") + + return Response( + media_type=MediaType.TEXT, + content=detail, + status_code=status_code, + ) + + +async def after_exception_handler(exc: Exception, scope: "Scope") -> None: + """Hook function that will be invoked after each exception.""" + state = scope["app"].state + if not hasattr(state, "error_count"): + state.error_count = 1 + else: + state.error_count += 1 + + logger.error( + f"an exception of type {type(exc).__name__} has occurred for requested path {scope['path']} and the application error count is {state.error_count}.", + ) + + +def create_app(): + return Litestar( + route_handlers=[ + get_health, + login, + get_index, + get_tts_overlay, + WebsocketHandler, + ], + static_files_config=( + StaticFilesConfig( + path="/tts_files", + directories=[Settings.get().tts_cache_path], + ), + StaticFilesConfig( + path="static", + directories=[Settings.get().static_files_path], + ), + ), + template_config=TemplateConfig( + directory=Settings.get().templates_files_path, + engine=JinjaTemplateEngine, + ), + exception_handlers={HTTPException: exception_handler}, + after_exception=[after_exception_handler], + ) + + +if __name__ == "__main__": + settings = Settings.get() + app = create_app() + uvicorn.run(app, host=settings.host, port=settings.port) diff --git a/src/huesoporro/settings.py b/src/huesoporro/settings.py new file mode 100644 index 0000000..f6c1f05 --- /dev/null +++ b/src/huesoporro/settings.py @@ -0,0 +1,48 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, HttpUrl, field_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + port: int = 8000 + host: str = "0.0.0.0" # noqa: S104 + static_files_path: Path = Field( + default_factory=lambda: Path(__file__).parent / "static" + ) + templates_files_path: Path = Field( + default_factory=lambda: Path(__file__).parent / "templates" + ) + tts_cache_path: Path = Field( + default_factory=lambda: Path(__file__).parent / "tts_files" + ) + db_filepath: Path = Field( + default_factory=lambda: Path(__file__).parent / "huesoporro.db" + ) + twitch_client_id: str + twitch_scopes: list[str] = Field( + default_factory=lambda: ["channel:bot", "chat:edit", "chat:read"] + ) + allowed_users: list[str] | str = Field(default_factory=lambda: ["huesoporro"]) + server_hostname: HttpUrl = "http://localhost:8000" + + @staticmethod + @lru_cache(maxsize=1) + def get(): + return Settings() # type: ignore[call-arg] # pydantic-setting magic + + @field_validator("allowed_users") + @classmethod + def validate_allowed_users(cls, value: list[str] | str): + # Convert string to list if necessary + if isinstance(value, str): + value = value.split(",") + return value + + @field_validator("tts_cache_path") + @classmethod + def validate_tts_cache_path(cls, value: Path): + # create path if it doesn't exist + value.mkdir(parents=True, exist_ok=True) + return value diff --git a/src/huesoporro/static/css/mvp.css b/src/huesoporro/static/css/mvp.css new file mode 100644 index 0000000..da3d654 --- /dev/null +++ b/src/huesoporro/static/css/mvp.css @@ -0,0 +1,594 @@ +/* MVP.css v1.17 - https://github.com/andybrewer/mvp */ + +:root { + --active-brightness: 0.85; + --border-radius: 5px; + --box-shadow: 2px 2px 10px; + --color-accent: #118bee15; + --color-bg: #fff; + --color-bg-secondary: #e9e9e9; + --color-link: #118bee; + --color-secondary: #920de9; + --color-secondary-accent: #920de90b; + --color-shadow: #f4f4f4; + --color-table: #118bee; + --color-text: #000; + --color-text-secondary: #999; + --color-scrollbar: #cacae8; + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + --hover-brightness: 1.2; + --justify-important: center; + --justify-normal: left; + --line-height: 1.5; + --width-card: 285px; + --width-card-medium: 460px; + --width-card-wide: 800px; + --width-content: 1080px; +} + +@media (prefers-color-scheme: dark) { + :root[color-mode="user"] { + --color-accent: #0097fc4f; + --color-bg: #333; + --color-bg-secondary: #555; + --color-link: #0097fc; + --color-secondary: #e20de9; + --color-secondary-accent: #e20de94f; + --color-shadow: #bbbbbb20; + --color-table: #0097fc; + --color-text: #f7f7f7; + --color-text-secondary: #aaa; + } +} + +html { + scroll-behavior: smooth; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } +} + +/* Layout */ +article aside { + background: var(--color-secondary-accent); + border-left: 4px solid var(--color-secondary); + padding: 0.01rem 0.8rem; +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family); + line-height: var(--line-height); + margin: 0; + overflow-x: hidden; + padding: 0; +} + +footer, +header, +main { + margin: 0 auto; + max-width: var(--width-content); + padding: 3rem 1rem; +} + +hr { + background-color: var(--color-bg-secondary); + border: none; + height: 1px; + margin: 4rem 0; + width: 100%; +} + +section { + display: flex; + flex-wrap: wrap; + justify-content: var(--justify-important); +} + +section img, +article img { + max-width: 100%; +} + +section pre { + overflow: auto; +} + +section aside { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + margin: 1rem; + padding: 1.25rem; + width: var(--width-card); +} + +section aside:hover { + box-shadow: var(--box-shadow) var(--color-bg-secondary); +} + +[hidden] { + display: none; +} + +/* Headers */ +article header, +div header, +main header { + padding-top: 0; +} + +header { + text-align: var(--justify-important); +} + +header a b, +header a em, +header a i, +header a strong { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +header nav img { + margin: 1rem 0; +} + +section header { + padding-top: 0; + width: 100%; +} + +/* Nav */ +nav { + align-items: center; + display: flex; + font-weight: bold; + justify-content: space-between; + margin-bottom: 7rem; +} + +nav ul { + list-style: none; + padding: 0; +} + +nav ul li { + display: inline-block; + margin: 0 0.5rem; + position: relative; + text-align: left; +} + +/* Nav Dropdown */ +nav ul li:hover ul { + display: block; +} + +nav ul li ul { + background: var(--color-bg); + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: none; + height: auto; + left: -2px; + padding: 0.5rem 1rem; + position: absolute; + top: 1.7rem; + white-space: nowrap; + width: auto; + z-index: 1; +} + +nav ul li ul::before { + /* fill gap above to make mousing over them easier */ + content: ""; + position: absolute; + left: 0; + right: 0; + top: -0.5rem; + height: 0.5rem; +} + +nav ul li ul li, +nav ul li ul li a { + display: block; +} + +/* Nav for Mobile */ +@media (max-width: 768px) { + nav { + flex-wrap: wrap; + } + + nav ul li { + width: calc(100% - 1em); + } + + nav ul li ul { + border: none; + box-shadow: none; + display: block; + position: static; + } +} + +/* Typography */ +code, +samp { + background-color: var(--color-accent); + border-radius: var(--border-radius); + color: var(--color-text); + display: inline-block; + margin: 0 0.1rem; + padding: 0 0.5rem; +} + +details { + margin: 1.3rem 0; +} + +details summary { + font-weight: bold; + cursor: pointer; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: var(--line-height); + text-wrap: balance; +} + +mark { + padding: 0.1rem; +} + +ol li, +ul li { + padding: 0.2rem 0; +} + +p { + margin: 0.75rem 0; + padding: 0; + width: 100%; +} + +pre { + margin: 1rem 0; + max-width: var(--width-card-wide); + padding: 1rem 0; +} + +pre code, +pre samp { + display: block; + max-width: var(--width-card-wide); + padding: 0.5rem 2rem; + white-space: pre-wrap; +} + +small { + color: var(--color-text-secondary); +} + +sup { + background-color: var(--color-secondary); + border-radius: var(--border-radius); + color: var(--color-bg); + font-size: xx-small; + font-weight: bold; + margin: 0.2rem; + padding: 0.2rem 0.3rem; + position: relative; + top: -2px; +} + +/* Links */ +a { + color: var(--color-link); + display: inline-block; + font-weight: bold; + text-decoration: underline; +} + +a:hover { + filter: brightness(var(--hover-brightness)); +} + +a:active { + filter: brightness(var(--active-brightness)); +} + +a b, +a em, +a i, +a strong, +button, +input[type="submit"] { + border-radius: var(--border-radius); + display: inline-block; + font-size: medium; + font-weight: bold; + line-height: var(--line-height); + margin: 0.5rem 0; + padding: 1rem 2rem; +} + +button, +input[type="submit"] { + font-family: var(--font-family); +} + +button:hover, +input[type="submit"]:hover { + cursor: pointer; + filter: brightness(var(--hover-brightness)); +} + +button:active, +input[type="submit"]:active { + filter: brightness(var(--active-brightness)); +} + +a b, +a strong, +button, +input[type="submit"] { + background-color: var(--color-link); + border: 2px solid var(--color-link); + color: var(--color-bg); +} + +a em, +a i { + border: 2px solid var(--color-link); + border-radius: var(--border-radius); + color: var(--color-link); + display: inline-block; + padding: 1rem 2rem; +} + +article aside a { + color: var(--color-secondary); +} + +/* Images */ +figure { + margin: 0; + padding: 0; +} + +figure img { + max-width: 100%; +} + +figure figcaption { + color: var(--color-text-secondary); +} + +/* Forms */ +button:disabled, +input:disabled { + background: var(--color-bg-secondary); + border-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + cursor: not-allowed; +} + +button[disabled]:hover, +input[type="submit"][disabled]:hover { + filter: none; +} + +form { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: block; + max-width: var(--width-card-wide); + min-width: var(--width-card); + padding: 1.5rem; + text-align: var(--justify-normal); +} + +form header { + margin: 1.5rem 0; + padding: 1.5rem 0; +} + +input, +label, +select, +textarea { + display: block; + font-size: inherit; + max-width: var(--width-card-wide); +} + +input[type="checkbox"], +input[type="radio"] { + display: inline-block; +} + +input[type="checkbox"] + label, +input[type="radio"] + label { + display: inline-block; + font-weight: normal; + position: relative; + top: 1px; +} + +input[type="range"] { + padding: 0.4rem 0; +} + +input, +select, +textarea { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + margin-bottom: 1rem; + padding: 0.4rem 0.8rem; +} + +input[type="text"], +input[type="password"], +textarea { + width: calc(100% - 1.6rem); +} + +input[readonly], +textarea[readonly] { + background-color: var(--color-bg-secondary); +} + +label { + font-weight: bold; + margin-bottom: 0.2rem; +} + +/* Popups */ +dialog { + max-width: 90%; + max-height: 85dvh; + margin: auto; + padding: 0; + border: 1px solid var(--color-bg-secondary); + border-radius: 0.5rem; + overscroll-behavior: contain; + scroll-behavior: smooth; + scrollbar-width: none; /* Hide scrollbar for Firefox */ + -ms-overflow-style: none; /* Hide scrollbar for IE and Edge */ + scrollbar-color: transparent transparent; + animation: bottom-to-top 0.25s ease-in-out forwards; +} + +dialog::-webkit-scrollbar { + width: 0; + display: none; +} + +dialog::-webkit-scrollbar-track { + background: transparent; +} + +dialog::-webkit-scrollbar-thumb { + background-color: transparent; +} + +@media (min-width: 650px) { + dialog { + max-width: 39rem; + } +} + +dialog::backdrop { + background-color: rgba(0, 0, 0, 0.5); +} + +@keyframes bottom-to-top { + 0% { + opacity: 0; + transform: translateY(10%); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +/* Tables */ +table { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + border-spacing: 0; + display: inline-block; + max-width: 100%; + overflow-x: auto; + padding: 0; + white-space: nowrap; +} + +table td, +table th, +table tr { + padding: 0.4rem 0.8rem; + text-align: var(--justify-important); +} + +table thead { + background-color: var(--color-table); + border-collapse: collapse; + border-radius: var(--border-radius); + color: var(--color-bg); + margin: 0; + padding: 0; +} + +table thead tr:first-child th:first-child { + border-top-left-radius: var(--border-radius); +} + +table thead tr:first-child th:last-child { + border-top-right-radius: var(--border-radius); +} + +table thead th:first-child, +table tr td:first-child { + text-align: var(--justify-normal); +} + +table tr:nth-child(even) { + background-color: var(--color-accent); +} + +/* Quotes */ +blockquote { + display: block; + font-size: x-large; + line-height: var(--line-height); + margin: 1rem auto; + max-width: var(--width-card-medium); + padding: 1.5rem 1rem; + text-align: var(--justify-important); +} + +blockquote footer { + color: var(--color-text-secondary); + display: block; + font-size: small; + line-height: var(--line-height); + padding: 1.5rem 0; +} + +/* Scrollbars */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-scrollbar) transparent; +} + +*::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--color-scrollbar); + border-radius: 10px; +} diff --git a/src/huesoporro/static/js/utils.js b/src/huesoporro/static/js/utils.js new file mode 100644 index 0000000..ed9a523 --- /dev/null +++ b/src/huesoporro/static/js/utils.js @@ -0,0 +1,18 @@ +function getWebsocketProtocol() { + // return "ws://" when localhost or "wss://" + const hostname = window.location.hostname; + if (hostname === "localhost" || hostname === "127.0.0.1") { + return "ws://"; + } else { + return "wss://"; + } +} + +function addLogoutEvent() { + const logoutButton = document.getElementById("logoutButton"); + logoutButton.addEventListener("click", () => { + document.cookie = "twitchLoginData=; expires=Thu, 01 Jan 1970 00:00:00 UTC"; + window.location.href = "/"; + }); + +} \ No newline at end of file diff --git a/src/huesoporro/templates/header.html b/src/huesoporro/templates/header.html new file mode 100644 index 0000000..f4bc8d6 --- /dev/null +++ b/src/huesoporro/templates/header.html @@ -0,0 +1,16 @@ + + + +
+ + + + + + + + + +