├── .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 | 
8 |
9 |
10 |
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 | 
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 |
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 %}
--------------------------------------------------------------------------------