├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── example.env ├── manage.py ├── media └── demo.gif ├── prototype ├── __init__.py ├── asgi.py ├── helpers │ └── openai_helper.py ├── settings.py ├── urls.py └── wsgi.py ├── requirements.txt ├── setup.sh ├── static ├── css │ └── styles.css ├── favicon.ico └── js │ └── editor.js ├── tapnote ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── views.py └── templates └── tapnote ├── 404.html ├── base.html ├── editor.html └── view_note.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Django-specific files 7 | *.log 8 | db.sqlite3 9 | 10 | # Environment files 11 | .env 12 | 13 | # Python virtual environment 14 | venv/ 15 | *.venv/ 16 | env/ 17 | *.env/ 18 | 19 | # IDE/editor specific files 20 | .vscode/ 21 | .idea/ 22 | *.swp 23 | *.swo 24 | 25 | # System-specific files 26 | .DS_Store 27 | Thumbs.db 28 | 29 | # Coverage reports 30 | .coverage 31 | coverage.xml 32 | htmlcov/ 33 | *.cover 34 | 35 | # Testing 36 | .pytest_cache/ 37 | .tox/ 38 | nosetests.xml 39 | *.pytest_cache/ 40 | 41 | # Static and media files (if not tracked) 42 | staticfiles/ 43 | 44 | # snap2txt 45 | project_contents.txt -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | WORKDIR /app 3 | 4 | ENV PYTHONDONTWRITEBYTECODE 1 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | COPY requirements.txt . 8 | RUN pip install --no-cache-dir -r requirements.txt 9 | 10 | # Copy all local files to /app 11 | COPY . . 12 | 13 | # Ensure proper permissions 14 | RUN chmod +x manage.py 15 | RUN chmod -R 755 . 16 | 17 | EXPOSE 9009 18 | 19 | # Collect static files, apply migrations, and run the server 20 | CMD ["bash", "-c", "python manage.py migrate --noinput && python manage.py collectstatic --noinput && python manage.py runserver 0.0.0.0:9009"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 Sergei Vorniches 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TapNote 2 | 3 | TapNote is a minimalist, self-hosted publishing platform inspired by Telegra.ph, focusing on instant Markdown-based content creation. It provides a distraction-free writing experience with instant publishing capabilities, making it perfect for quick notes, blog posts, or documentation sharing. 4 | 5 | > Check out the report on creating TapNote on [dev.to](https://dev.to/vorniches/building-self-hosted-telegraph-in-1-prompt-and-3-minutes-2li2) or [YouTube](https://youtu.be/ArPGGaG5EU8). 6 | 7 | ![Demo](media/demo.gif) 8 |

9 | 10 | Live Demo 11 | 12 |

13 | 14 | ## Features 15 | 16 | - **Minimalist Writing Experience** 17 | - Clean, distraction-free Markdown editor 18 | - No account required 19 | - Instant publishing with a single click 20 | - Support for full Markdown syntax 21 | - Self-hosted: maintain full control over your content 22 | 23 | - **Content Management** 24 | - Unique URL for each post 25 | - Edit functionality with secure tokens 26 | - Proper rendering of all Markdown elements 27 | - Support for images and code snippets 28 | 29 | ## Quick Start 30 | 31 | 1. Clone the repository: 32 | ```bash 33 | git clone https://github.com/vorniches/tapnote.git 34 | cd tapnote 35 | ``` 36 | 37 | 2. Start the application using Docker: 38 | ```bash 39 | chmod +x setup.sh 40 | ./setup.sh 41 | ``` 42 | 43 | 3. Access TapNote at `http://localhost:9009` 44 | 45 | ## Examples 46 | 47 | ```Markdown 48 | # Heading 1 49 | Some paragraph text here. 50 | 51 | ![Image](https://themepreview.home.blog/wp-content/uploads/2019/07/boat.jpg) 52 | 53 | ## Heading 2 54 | Another paragraph of text with some **bold** text, *italic* text, and ~~strikethrough~~ text. 55 | 56 | ### Heading 3 57 | 1. An ordered list item 58 | 2. Another ordered list item 59 | 60 | '```python 61 | # Some Python code snippet 62 | def greet(name): 63 | return f"Hello, {name}!" 64 | ```' 65 | 66 | #### Heading 4 67 | A quote block: 68 | > This is a blockquote! 69 | 70 | - Sub list item (unordered) 71 | - Sub list item (unordered) 72 | 73 | #### Table Example 74 | | Column A | Column B | 75 | |----------|----------| 76 | | Cell 1A | Cell 1B | 77 | | Cell 2A | Cell 2B | 78 | 79 | https://youtu.be/vz91QpgUjFc?si=6nTE2LeukJprXiw1 80 | ``` 81 | 82 | > Note: For correct rendering of code exmaple remove `'` symbols. 83 | 84 | ## Deploying 85 | 86 | Deploy TapNote on a server in a few clicks and connect a custom domain with [RailWay](https://railway.com?referralCode=eKC9tt) or your preferred service. 87 | 88 | ## Contributing 89 | 90 | Feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. 91 | 92 | ## License 93 | 94 | [MIT License](LICENSE) 95 | 96 | ## Acknowledgments 97 | 98 | - Inspired by [Telegra.ph](https://telegra.ph) 99 | - Built with Django and Tailwind CSS 100 | - Kickstarted in minutes using [Prototype](https://github.com/vorniches/prototype), [snap2txt](https://github.com/vorniches/snap2txt) and [Cursor](https://cursor.so) 101 | - Uses Space Mono font by Google Fonts 102 | 103 | ## Support 104 | 105 | - Create an issue for bug reports or feature requests 106 | - Star the repository if you find it useful 107 | - Fork it to contribute or create your own version -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | build: . 5 | ports: 6 | - "9009:9009" 7 | volumes: 8 | - .:/app -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'prototype.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /media/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vorniches/tapnote/791148aeb371c6c2e88a6da2b1e8e4c558c8fc1e/media/demo.gif -------------------------------------------------------------------------------- /prototype/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vorniches/tapnote/791148aeb371c6c2e88a6da2b1e8e4c558c8fc1e/prototype/__init__.py -------------------------------------------------------------------------------- /prototype/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for prototype project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'prototype.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /prototype/helpers/openai_helper.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | from django.conf import settings 4 | from openai import OpenAI 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | def send_prompt_to_openai( 9 | system_content: str, 10 | user_prompt: str, 11 | model: str = "gpt-4o-mini", 12 | max_tokens: int = 1000, 13 | temperature: float = 0.7, 14 | ): 15 | """ 16 | Sends a prompt to the OpenAI Chat Completion endpoint using the same style 17 | of request your code already uses. Returns the content string or None on error. 18 | """ 19 | try: 20 | # Create the OpenAI client with the existing approach 21 | client = OpenAI(api_key=settings.OPENAI_API_KEY) 22 | 23 | # Make the chat completion request 24 | chat_completion = client.chat.completions.create( 25 | model=model, 26 | messages=[ 27 | {"role": "system", "content": system_content}, 28 | {"role": "user", "content": user_prompt} 29 | ], 30 | max_tokens=max_tokens, 31 | temperature=temperature 32 | ) 33 | 34 | if not chat_completion.choices: 35 | logger.error("No completion choices returned by OpenAI.") 36 | return None 37 | 38 | response_content = chat_completion.choices[0].message.content.strip() 39 | logger.debug(f"Raw response from OpenAI: {repr(response_content)}") 40 | 41 | return response_content 42 | 43 | except Exception as e: 44 | logger.error(f"Error calling OpenAI: {str(e)}") 45 | return None 46 | 47 | def parse_json_response(response_content: str) -> dict: 48 | """ 49 | Attempts to parse a string `response_content` as JSON. 50 | Returns a Python dict on success, or None on failure. 51 | """ 52 | try: 53 | # In case the model includes ```json ... ```, remove those 54 | cleaned = response_content.replace("```json", "").replace("```", "").strip() 55 | data = json.loads(cleaned) 56 | return data 57 | except json.JSONDecodeError as e: 58 | logger.error(f"JSON parse error: {e}") 59 | return None 60 | -------------------------------------------------------------------------------- /prototype/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for prototype project. 3 | 4 | Generated by 'django-admin startproject' using Django 4.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/4.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | STATIC_URL = '/static/' 18 | STATIC_ROOT = BASE_DIR / 'staticfiles' 19 | 20 | # Quick-start development settings - unsuitable for production 21 | # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ 22 | 23 | # SECURITY WARNING: keep the secret key used in production secret! 24 | SECRET_KEY = 'django-insecure-0p*ria)n_#i%&6%ei#j@c8oyclnve0&bac2e)n0rjd+!c4p9x3' 25 | 26 | # SECURITY WARNING: don't run with debug turned on in production! 27 | DEBUG = False 28 | 29 | ALLOWED_HOSTS = ['*'] # For development - update this in production 30 | 31 | APPEND_SLASH = False 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'tapnote', # Add our new app 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | 'whitenoise.middleware.WhiteNoiseMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'prototype.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [BASE_DIR / 'templates'], # Add templates directory 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'prototype.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/4.2/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': { 82 | 'ENGINE': 'django.db.backends.sqlite3', 83 | 'NAME': BASE_DIR / 'db.sqlite3', 84 | } 85 | } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 100 | }, 101 | { 102 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 103 | }, 104 | ] 105 | 106 | 107 | # Internationalization 108 | # https://docs.djangoproject.com/en/4.2/topics/i18n/ 109 | 110 | LANGUAGE_CODE = 'en-us' 111 | 112 | TIME_ZONE = 'UTC' 113 | 114 | USE_I18N = True 115 | 116 | USE_TZ = True 117 | 118 | 119 | # Static files (CSS, JavaScript, Images) 120 | # https://docs.djangoproject.com/en/4.2/howto/static-files/ 121 | 122 | STATIC_URL = 'static/' 123 | STATICFILES_DIRS = [ 124 | BASE_DIR / 'static', 125 | ] 126 | 127 | # Add media files configuration 128 | MEDIA_URL = '/media/' 129 | MEDIA_ROOT = BASE_DIR / 'media' 130 | 131 | # Default primary key field type 132 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field 133 | 134 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 135 | 136 | # Added for production CSRF checks 137 | CSRF_TRUSTED_ORIGINS = ['https://tapnote-production.up.railway.app'] -------------------------------------------------------------------------------- /prototype/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for prototype project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/4.2/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path 19 | from tapnote import views 20 | from django.conf.urls.static import static 21 | from django.conf import settings 22 | 23 | handler404 = 'tapnote.views.handler404' 24 | 25 | urlpatterns = [ 26 | path('admin/', admin.site.urls), 27 | path('', views.home, name='home'), 28 | path('publish/', views.publish, name='publish'), 29 | path('/', views.view_note, name='view_note'), 30 | path('/edit/', views.edit_note, name='edit_note'), 31 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 32 | -------------------------------------------------------------------------------- /prototype/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for prototype project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'prototype.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Django==4.2.2 2 | openai==1.3.0 3 | python-telegram-bot==20.6 4 | python-dotenv==0.19.2 5 | django-tailwind==3.8.0 6 | Markdown==3.4.1 7 | whitenoise==6.5.0 -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 1) Create Django project if it doesn't exist yet 4 | # (If you already have the `prototype` folder with settings.py, you can skip startproject.) 5 | if [ ! -d "prototype" ]; then 6 | docker run --rm -v "$(pwd)":/app -w /app python:3.10 \ 7 | bash -c "pip install django==4.2.2 && django-admin startproject prototype ." 8 | fi 9 | 10 | # 2) Make sure you have an __init__.py, just in case 11 | touch prototype/__init__.py 12 | 13 | # 3) Create helpers folder and move openai_helper.py into it if it exists 14 | mkdir -p prototype/helpers 15 | if [ -f "openai_helper.py" ]; then 16 | mv openai_helper.py prototype/helpers/ 17 | fi 18 | 19 | # 4) Build & run containers 20 | docker-compose build --no-cache 21 | docker-compose up -d 22 | -------------------------------------------------------------------------------- /static/css/styles.css: -------------------------------------------------------------------------------- 1 | /* Base styles */ 2 | body { 3 | font-family: 'Space Mono', monospace; 4 | } 5 | 6 | /* Markdown content styles */ 7 | .markdown-content h1 { 8 | font-size: 2em; 9 | font-weight: bold; 10 | margin: 1em 0; 11 | } 12 | 13 | .markdown-content h2 { 14 | font-size: 1.5em; 15 | font-weight: bold; 16 | margin: 0.83em 0; 17 | } 18 | 19 | .markdown-content h3 { 20 | font-size: 1.17em; 21 | font-weight: bold; 22 | margin: 1em 0; 23 | } 24 | 25 | .markdown-content h4 { 26 | font-size: 1em; 27 | font-weight: bold; 28 | margin: 1em 0; 29 | } 30 | 31 | .markdown-content p { 32 | margin: 1em 0; 33 | } 34 | 35 | .markdown-content pre { 36 | background-color: #f5f5f5; 37 | padding: 1em; 38 | border-radius: 0.5em; 39 | overflow-x: auto; 40 | } 41 | 42 | .markdown-content code { 43 | font-family: 'Space Mono', monospace; 44 | } 45 | 46 | .markdown-content a { 47 | color: #000; 48 | text-decoration: underline; 49 | } 50 | 51 | .markdown-content img { 52 | max-width: 100%; 53 | height: auto; 54 | } 55 | 56 | .markdown-content blockquote { 57 | border-left: 4px solid #ccc; 58 | padding-left: 1em; 59 | margin: 1em 0; 60 | color: #555; 61 | } 62 | 63 | .markdown-content ol { 64 | list-style-type: decimal; 65 | list-style-position: outside; 66 | margin-left: 0; 67 | padding-left: 3em; 68 | } 69 | 70 | .markdown-content ul { 71 | list-style-type: disc; 72 | list-style-position: outside; 73 | margin-left: 0; 74 | padding-left: 2em; 75 | } 76 | 77 | .markdown-content li { 78 | margin: 0.5em 0; 79 | } 80 | 81 | .markdown-content strong { 82 | font-weight: bold; 83 | } 84 | 85 | .markdown-content em { 86 | font-style: italic; 87 | } 88 | 89 | .markdown-content del { 90 | text-decoration: line-through; 91 | } 92 | 93 | .markdown-content hr { 94 | border: 0; 95 | border-top: 1px solid #ccc; 96 | margin: 1.5em 0; 97 | } 98 | 99 | .markdown-content table { 100 | border-collapse: collapse; 101 | width: 100%; 102 | margin: 1em 0; 103 | } 104 | 105 | .markdown-content th, 106 | .markdown-content td { 107 | border: 1px solid #ccc; 108 | padding: 0.5em; 109 | } -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vorniches/tapnote/791148aeb371c6c2e88a6da2b1e8e4c558c8fc1e/static/favicon.ico -------------------------------------------------------------------------------- /static/js/editor.js: -------------------------------------------------------------------------------- 1 | document.addEventListener('DOMContentLoaded', function() { 2 | const textarea = document.querySelector('textarea[name="content"]'); 3 | if (textarea) { 4 | // Auto-grow function 5 | function autoGrow(elem) { 6 | elem.style.height = 'auto'; 7 | elem.style.height = (elem.scrollHeight) + 'px'; 8 | } 9 | 10 | // Add input listener 11 | textarea.addEventListener('input', function() { 12 | autoGrow(textarea); 13 | }); 14 | 15 | // Initialize on load 16 | autoGrow(textarea); 17 | } 18 | }); -------------------------------------------------------------------------------- /tapnote/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.2 on 2025-01-25 12:23 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Note', 17 | fields=[ 18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('hashcode', models.CharField(max_length=32, unique=True)), 20 | ('content', models.TextField()), 21 | ('edit_token', models.CharField(max_length=64)), 22 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tapnote/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vorniches/tapnote/791148aeb371c6c2e88a6da2b1e8e4c558c8fc1e/tapnote/migrations/__init__.py -------------------------------------------------------------------------------- /tapnote/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | class Note(models.Model): 6 | hashcode = models.CharField(max_length=32, unique=True) 7 | content = models.TextField() 8 | edit_token = models.CharField(max_length=64) 9 | created_at = models.DateTimeField(default=timezone.now) 10 | updated_at = models.DateTimeField(auto_now=True) 11 | 12 | def __str__(self): 13 | return f"Note {self.hashcode}" 14 | 15 | def save(self, *args, **kwargs): 16 | if not self.hashcode: 17 | self.hashcode = uuid.uuid4().hex 18 | if not self.edit_token: 19 | self.edit_token = uuid.uuid4().hex 20 | super().save(*args, **kwargs) -------------------------------------------------------------------------------- /tapnote/views.py: -------------------------------------------------------------------------------- 1 | import markdown 2 | from django.shortcuts import render, get_object_or_404, redirect 3 | from django.http import Http404 4 | from django.views.decorators.csrf import csrf_exempt 5 | from .models import Note 6 | import re 7 | 8 | def apply_strikethrough(md_text): 9 | # Replace ~~something~~ with something 10 | pattern = re.compile(r'~~(.*?)~~', re.DOTALL) 11 | return pattern.sub(r'\1', md_text) 12 | 13 | def process_markdown_links(html_content): 14 | # Keep existing link processing 15 | pattern = r'' 16 | replacement = r'' 17 | html_content = re.sub(pattern, replacement, html_content) 18 | 19 | # Existing anchor-based YouTube embed: 20 | anchor_yt_pattern = r'

.*?

' 21 | anchor_yt_replacement = ( 22 | r'' 25 | ) 26 | html_content = re.sub(anchor_yt_pattern, anchor_yt_replacement, html_content) 27 | 28 | # **Added** plain-text YouTube embed (no anchor tag): 29 | plain_yt_pattern = r'

https?://(?:www\.)?youtu\.be/([^<]+)

' 30 | plain_yt_replacement = ( 31 | r'' 34 | ) 35 | html_content = re.sub(plain_yt_pattern, plain_yt_replacement, html_content) 36 | 37 | return html_content 38 | 39 | def home(request): 40 | return render(request, 'tapnote/editor.html') 41 | 42 | @csrf_exempt 43 | def publish(request): 44 | if request.method == 'POST': 45 | content = request.POST.get('content', '').strip() 46 | if content: 47 | note = Note.objects.create(content=content) 48 | response = redirect('view_note', hashcode=note.hashcode) 49 | response.set_cookie(f'edit_token_{note.hashcode}', note.edit_token, max_age=31536000) 50 | return response 51 | return redirect('home') 52 | 53 | def view_note(request, hashcode): 54 | note = get_object_or_404(Note, hashcode=hashcode) 55 | 56 | # FIRST apply strikethrough by regex 57 | raw_with_del = apply_strikethrough(note.content) 58 | 59 | # THEN convert with standard Markdown (no strikethrough extension) 60 | md = markdown.Markdown(extensions=['fenced_code', 'tables']) 61 | html_content = md.convert(raw_with_del) 62 | html_content = process_markdown_links(html_content) 63 | 64 | can_edit = ( 65 | request.COOKIES.get(f'edit_token_{note.hashcode}') == note.edit_token or 66 | request.GET.get('token') == note.edit_token 67 | ) 68 | 69 | return render(request, 'tapnote/view_note.html', { 70 | 'note': note, 71 | 'content': html_content, 72 | 'can_edit': can_edit, 73 | }) 74 | 75 | def edit_note(request, hashcode): 76 | note = get_object_or_404(Note, hashcode=hashcode) 77 | edit_token = request.COOKIES.get(f'edit_token_{note.hashcode}') 78 | url_token = request.GET.get('token') 79 | 80 | if edit_token != note.edit_token and url_token != note.edit_token: 81 | raise Http404() 82 | 83 | if request.method == 'POST': 84 | content = request.POST.get('content', '').strip() 85 | if content: 86 | note.content = content 87 | note.save() 88 | return redirect('view_note', hashcode=note.hashcode) 89 | 90 | return render(request, 'tapnote/editor.html', {'note': note}) 91 | 92 | def handler404(request, exception): 93 | return render(request, 'tapnote/404.html', status=404) 94 | -------------------------------------------------------------------------------- /templates/tapnote/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'tapnote/base.html' %} 2 | 3 | {% block content %} 4 |
5 |

404

6 |
7 | {% endblock %} -------------------------------------------------------------------------------- /templates/tapnote/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | TapNote 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% block content %}{% endblock %} 15 | {% block extra_js %}{% endblock %} 16 | 17 | 18 | -------------------------------------------------------------------------------- /templates/tapnote/editor.html: -------------------------------------------------------------------------------- 1 | {% extends 'tapnote/base.html' %} 2 | 3 | {% load static %} 4 | 5 | {% block content %} 6 |
7 |
8 | {% csrf_token %} 9 | 15 |
16 | 19 |
20 |
21 |
22 | {% endblock %} 23 | 24 | {% block extra_js %} 25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /templates/tapnote/view_note.html: -------------------------------------------------------------------------------- 1 | {% extends 'tapnote/base.html' %} 2 | 3 | {% block content %} 4 |
5 | {% if can_edit %} 6 | 11 | {% endif %} 12 |
13 | {{ content|safe }} 14 |
15 |
16 | {% endblock %} --------------------------------------------------------------------------------