mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
+1
-1
@@ -13,7 +13,7 @@ db.sqlite3-journal
|
||||
/media/
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv
|
||||
.env
|
||||
|
||||
# IDE specific files
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
@@ -1,70 +0,0 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
export VIRTUAL_ENV=$(cygpath "/Users/junv/code/links/3.12")
|
||||
else
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV="/Users/junv/code/links/3.12"
|
||||
fi
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1="(3.12) ${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT="(3.12) "
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from argcomplete.scripts.activate_global_python_argcomplete import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,27 +0,0 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV "/Users/junv/code/links/3.12"
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = "(3.12) $prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT "(3.12) "
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
@@ -1,69 +0,0 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV "/Users/junv/code/links/3.12"
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) "(3.12) " (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT "(3.12) "
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from chardet.cli.chardetect import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from cookiecutter.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from django.core.management import execute_from_command_line
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(execute_from_command_line())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from gunicorn.app.wsgiapp import run
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(run())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from markdown_it.cli.parse import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from charset_normalizer.cli import cli_detect
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli_detect())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pipx.main import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pygments.cmdline import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1 +0,0 @@
|
||||
python3.12
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from argcomplete.scripts.python_argcomplete_check_easy_install_script import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1 +0,0 @@
|
||||
python3.12
|
||||
@@ -1 +0,0 @@
|
||||
/usr/local/opt/python@3.12/bin/python3.12
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from argcomplete.scripts.register_python_argcomplete import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from slugify.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from sqlparse.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/Users/junv/code/links/3.12/bin/python3.12
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from userpath.cli import userpath
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(userpath())
|
||||
@@ -1,5 +0,0 @@
|
||||
home = /usr/local/opt/python@3.12/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.12.5
|
||||
executable = /usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/bin/python3.12
|
||||
command = /usr/local/opt/python@3.12/bin/python3.12 -m venv /Users/junv/code/links/3.12
|
||||
+21
-18
@@ -1,35 +1,38 @@
|
||||
FROM ghcr.io/astral-sh/uv:bookworm-slim AS uv
|
||||
FROM python:3.12-slim
|
||||
FROM ghcr.io/astral-sh/uv:python3.12-bookworm
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
ENV UV_INSTALL_DIR /usr/local/bin
|
||||
ENV PYTHONPATH /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
UV_CACHE_DIR=/app/.cache/uv
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pyproject.toml
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install system dependencies and uv
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
curl \
|
||||
wget \
|
||||
gnupg \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
gcc \
|
||||
nodejs \
|
||||
npm \
|
||||
gettext \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy project
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies using uv
|
||||
RUN uv venv /app/.venv
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
|
||||
RUN uv sync --no-dev --frozen --no-install-project
|
||||
|
||||
# Copy the rest of the project
|
||||
COPY . .
|
||||
|
||||
COPY --from=uv /usr/local/bin/uv /usr/local/bin/uv
|
||||
# Install Python dependencies
|
||||
RUN uv venv && uv sync --no-dev
|
||||
# Sync the project
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
|
||||
Binary file not shown.
+11
-17
@@ -6,8 +6,8 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
command: >
|
||||
sh -c "uv run manage.py migrate &&
|
||||
uv run manage.py runserver 0.0.0.0:8000"
|
||||
bash -c "uv run python manage.py migrate &&
|
||||
uv run python manage.py runserver 0.0.0.0:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data/media:/app/data/media
|
||||
@@ -23,21 +23,20 @@ services:
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
node:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
command: >
|
||||
sh -c "uv run manage.py tailwind start"
|
||||
bash -c "npm install &&
|
||||
uv run python manage.py tailwind install &&
|
||||
uv run python manage.py tailwind start"
|
||||
volumes:
|
||||
- .:/app
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=core.settings
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- redis
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -64,7 +63,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
command: uv run celery -A core beat -s /app/data/celerybeat-schedule --loglevel=info
|
||||
command: uv run celery -A core beat -s /app/data/celery/celerybeat-schedule --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data/media:/app/data/media
|
||||
@@ -75,29 +74,28 @@ services:
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
- web
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
celery_flower:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
command: uv run celery -A core flower -s /app/data/celerybeat-schedule --loglevel=info
|
||||
command: uv run celery -A core flower --port=5555 --loglevel=info
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data/media:/app/data/media
|
||||
- ./data/celery:/app/data/celery
|
||||
ports:
|
||||
- "5555:5555"
|
||||
environment:
|
||||
- DJANGO_SETTINGS_MODULE=core.settings
|
||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||
depends_on:
|
||||
- redis
|
||||
- web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5555:5555"
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -108,10 +106,6 @@ services:
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
media_volume:
|
||||
static_volume:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -26,11 +26,11 @@ case "$1" in
|
||||
docker-compose build
|
||||
;;
|
||||
"shell")
|
||||
docker-compose exec web python manage.py shell
|
||||
docker-compose exec web bash
|
||||
;;
|
||||
"migrate")
|
||||
docker-compose exec web python manage.py makemigrations
|
||||
docker-compose exec web python manage.py migrate
|
||||
docker-compose exec web uv run manage.py makemigrations
|
||||
docker-compose exec web uv run manage.py migrate
|
||||
;;
|
||||
"tailwind-logs")
|
||||
docker-compose logs -f tailwind
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from . import page_views
|
||||
from . import newsletter_views
|
||||
|
||||
router = DefaultRouter(trailing_slash=False)
|
||||
router.register('pages', page_views.PageViewSet, basename='api-pages')
|
||||
router.register('newsletters', newsletter_views.NewsletterViewSet, basename='api-newsletters')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
+9
-1
@@ -1,5 +1,5 @@
|
||||
from django import forms
|
||||
from .models import Link, Page
|
||||
from .models import Link, Page, Newsletter
|
||||
from simplemde.fields import SimpleMDEField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.core.validators import URLValidator
|
||||
@@ -59,3 +59,11 @@ class PageForm(forms.ModelForm):
|
||||
'summary': forms.Textarea(attrs={'rows': 3}),
|
||||
'content': forms.Textarea(attrs={'rows': 10}),
|
||||
}
|
||||
|
||||
class NewsletterForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Newsletter
|
||||
fields = ['title', 'type', 'content']
|
||||
widgets = {
|
||||
'content': forms.Textarea(attrs={'rows': 20}),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.0.9 on 2024-11-05 10:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('links', '0012_page_screenshot_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Newsletter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='Title')),
|
||||
('type', models.CharField(choices=[('technology', 'Technology'), ('world_news', 'World News'), ('local_news', 'Local News')], default='technology', max_length=20, verbose_name='Type')),
|
||||
('content', models.TextField(verbose_name='Content')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Newsletter',
|
||||
'verbose_name_plural': 'Newsletters',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -176,3 +176,31 @@ class Page(models.Model):
|
||||
if self.screenshot_path:
|
||||
return f'/media/{self.screenshot_path}'
|
||||
return None
|
||||
|
||||
class Newsletter(models.Model):
|
||||
class NewsletterType(models.TextChoices):
|
||||
TECHNOLOGY = 'technology', _('Technology')
|
||||
WORLD_NEWS = 'world_news', _('World News')
|
||||
LOCAL_NEWS = 'local_news', _('Local News')
|
||||
|
||||
title = models.CharField(_('Title'), max_length=200)
|
||||
type = models.CharField(
|
||||
_('Type'),
|
||||
max_length=20,
|
||||
choices=NewsletterType.choices,
|
||||
default=NewsletterType.TECHNOLOGY
|
||||
)
|
||||
content = models.TextField(_('Content'))
|
||||
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = _('Newsletter')
|
||||
verbose_name_plural = _('Newsletters')
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('newsletter-detail', kwargs={'pk': self.pk})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.http import Http404
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from .models import Newsletter
|
||||
from .forms import NewsletterForm
|
||||
from .serializers import NewsletterSerializer
|
||||
|
||||
class NewsletterListView(ListView):
|
||||
model = Newsletter
|
||||
template_name = 'links/newsletter_list.html'
|
||||
context_object_name = 'newsletters'
|
||||
paginate_by = 10
|
||||
|
||||
class NewsletterDetailView(DetailView):
|
||||
model = Newsletter
|
||||
template_name = 'links/newsletter_detail.html'
|
||||
|
||||
class NewsletterCreateView(CreateView):
|
||||
model = Newsletter
|
||||
form_class = NewsletterForm
|
||||
template_name = 'links/newsletter_form.html'
|
||||
success_url = reverse_lazy('newsletter-list')
|
||||
|
||||
class NewsletterUpdateView(UpdateView):
|
||||
model = Newsletter
|
||||
form_class = NewsletterForm
|
||||
template_name = 'links/newsletter_form.html'
|
||||
success_url = reverse_lazy('newsletter-list')
|
||||
|
||||
class NewsletterDeleteView(DeleteView):
|
||||
model = Newsletter
|
||||
template_name = 'links/newsletter_confirm_delete.html'
|
||||
success_url = reverse_lazy('newsletter-list')
|
||||
|
||||
class PublicNewsletterView(DetailView):
|
||||
model = Newsletter
|
||||
template_name = 'links/public_newsletter.html'
|
||||
context_object_name = 'newsletter'
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
try:
|
||||
return super().get_object(queryset)
|
||||
except Http404:
|
||||
raise Http404("Newsletter not found")
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
class NewsletterViewSet(viewsets.ModelViewSet):
|
||||
queryset = Newsletter.objects.all().order_by('-created_at')
|
||||
serializer_class = NewsletterSerializer
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
page = self.paginate_queryset(queryset)
|
||||
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
+12
-1
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Page
|
||||
from .models import Page, Newsletter
|
||||
|
||||
class PageSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -8,3 +8,14 @@ class PageSerializer(serializers.ModelSerializer):
|
||||
'process_status', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'screenshot_path', 'process_status',
|
||||
'created_at', 'updated_at']
|
||||
|
||||
class NewsletterSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Newsletter
|
||||
fields = ['id', 'title', 'type', 'content', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
extra_kwargs = {
|
||||
'title': {'required': True},
|
||||
'type': {'required': True},
|
||||
'content': {'required': True}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -6,79 +7,11 @@
|
||||
<title>{{ link.alias }}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss-typography/0.4.0/typography.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 65ch;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: disc;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose ol {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose h1 {
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{% static 'css/markdown.css' %}">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="max-w-6xl mx-auto mt-10 p-4 sm:px-6 bg-white shadow-md rounded-lg">
|
||||
<div class="prose prose-sm sm:prose lg:prose-lg xl:prose-xl max-w-none">
|
||||
<div class="prose">
|
||||
{{ rendered_text|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +195,24 @@ Response example:
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### List newsletters:
|
||||
|
||||
```
|
||||
GET /api/newsletters/
|
||||
```
|
||||
|
||||
### Create a newsletter:
|
||||
```
|
||||
curl -X POST http://localhost:8000/api/newsletters \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Test Newsletter",
|
||||
"type": "technology",
|
||||
"content": "# Test Content\n\nThis is a test newsletter."
|
||||
}'
|
||||
```
|
||||
|
||||
{% endfilter %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% if is_paginated %}
|
||||
<div class="mt-4 flex justify-center">
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Previous" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
||||
{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}
|
||||
</span>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
{% trans "Next" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden max-w-4xl mx-auto">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-start space-x-4">
|
||||
<!-- Warning Icon -->
|
||||
<div class="flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100">
|
||||
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
{% trans "Delete Newsletter" %}
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
{% trans "Are you sure you want to delete this newsletter?" %}
|
||||
</p>
|
||||
<p class="mt-1 text-sm font-medium text-gray-900">
|
||||
"{{ object.title }}"
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
{% trans "This action cannot be undone." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<a href="{% url 'newsletter-detail' object.pk %}"
|
||||
class="inline-flex justify-center items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<form method="post" class="inline-block">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load markdown_extras %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/markdown.css' %}">
|
||||
<style>
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 1rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" class="modal" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="modal-content">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900" id="modal-title">
|
||||
{% trans "Delete Newsletter" %}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
{% trans "Are you sure you want to delete this newsletter? This action cannot be undone." %}
|
||||
</p>
|
||||
<div class="mt-4 flex space-x-3">
|
||||
<form method="post" action="{% url 'newsletter-delete' newsletter.pk %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button"
|
||||
onclick="closeDeleteModal()"
|
||||
class="inline-flex justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{% trans "Cancel" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<!-- Header with title and actions -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ newsletter.title }}</h1>
|
||||
<div class="mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if newsletter.type == 'technology' %}bg-purple-100 text-purple-800
|
||||
{% elif newsletter.type == 'world_news' %}bg-green-100 text-green-800
|
||||
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||
{{ newsletter.get_type_display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex space-x-2">
|
||||
<a href="{% url 'newsletter-update' newsletter.pk %}"
|
||||
class="p-2 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded-md"
|
||||
title="{% trans 'Edit Newsletter' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'public-newsletter' newsletter.pk %}"
|
||||
target="_blank"
|
||||
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-md"
|
||||
title="{% trans 'Open Public View' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
||||
</svg>
|
||||
</a>
|
||||
<button onclick="openDeleteModal()"
|
||||
class="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md"
|
||||
title="{% trans 'Delete Newsletter' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="{% url 'newsletter-list' %}"
|
||||
class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded-md"
|
||||
title="{% trans 'Back to List' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mb-6 text-sm text-gray-500">
|
||||
<div>{% trans "Created" %}: {{ newsletter.created_at|date:"Y-m-d H:i" }}</div>
|
||||
<div>{% trans "Last updated" %}: {{ newsletter.updated_at|date:"Y-m-d H:i" }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Content with improved markdown styling -->
|
||||
<article class="prose">
|
||||
{{ newsletter.content|markdown|safe }}
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function openDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.remove('show');
|
||||
document.body.style.overflow = 'auto';
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,133 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- SimpleMDE CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.css">
|
||||
<style>
|
||||
.CodeMirror {
|
||||
height: 400px;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.editor-toolbar {
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-top-right-radius: 0.375rem;
|
||||
}
|
||||
.CodeMirror {
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">
|
||||
{% if form.instance.pk %}
|
||||
{% trans "Edit Newsletter" %}
|
||||
{% else %}
|
||||
{% trans "New Newsletter" %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<form method="post" class="space-y-6">
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Title Field -->
|
||||
<div>
|
||||
<label for="{{ form.title.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ form.title.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<input type="text" name="{{ form.title.name }}" id="{{ form.title.id_for_label }}"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||
value="{{ form.title.value|default:'' }}"
|
||||
placeholder="{% trans 'Newsletter Title' %}">
|
||||
</div>
|
||||
{% if form.title.errors %}
|
||||
<p class="mt-2 text-sm text-red-600">{{ form.title.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Type Field -->
|
||||
<div>
|
||||
<label for="{{ form.type.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ form.type.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<select name="{{ form.type.name }}" id="{{ form.type.id_for_label }}"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||
{% for value, label in form.type.field.choices %}
|
||||
<option value="{{ value }}" {% if form.type.value == value %}selected{% endif %}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if form.type.errors %}
|
||||
<p class="mt-2 text-sm text-red-600">{{ form.type.errors.0 }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Content Field with SimpleMDE -->
|
||||
<div>
|
||||
<label for="{{ form.content.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||
{{ form.content.label }}
|
||||
</label>
|
||||
<div class="mt-1">
|
||||
<textarea name="{{ form.content.name }}" id="{{ form.content.id_for_label }}"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md">{{ form.content.value|default:'' }}</textarea>
|
||||
</div>
|
||||
{% if form.content.errors %}
|
||||
<p class="mt-2 text-sm text-red-600">{{ form.content.errors.0 }}</p>
|
||||
{% endif %}
|
||||
<p class="mt-2 text-sm text-gray-500">{% trans "Supports Markdown formatting" %}</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-5">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<a href="{% url 'newsletter-list' %}"
|
||||
class="inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- SimpleMDE JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/simplemde/latest/simplemde.min.js"></script>
|
||||
<script>
|
||||
var simplemde = new SimpleMDE({
|
||||
element: document.getElementById("{{ form.content.id_for_label }}"),
|
||||
spellChecker: false,
|
||||
autosave: {
|
||||
enabled: true,
|
||||
unique_id: "newsletter_content_{{ form.instance.pk|default:'new' }}"
|
||||
},
|
||||
toolbar: [
|
||||
"bold", "italic", "heading", "|",
|
||||
"quote", "unordered-list", "ordered-list", "|",
|
||||
"link", "image", "table", "|",
|
||||
"preview", "side-by-side", "fullscreen", "|",
|
||||
"guide"
|
||||
],
|
||||
status: ["autosave", "lines", "words", "cursor"],
|
||||
renderingConfig: {
|
||||
singleLineBreaks: false,
|
||||
codeSyntaxHighlighting: true,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{% trans "Newsletters" %}</h1>
|
||||
<a href="{% url 'newsletter-create' %}" class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700">
|
||||
{% trans "Add Newsletter" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-gray-200">
|
||||
{% for newsletter in newsletters %}
|
||||
<li class="hover:bg-gray-50">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<a href="{% url 'newsletter-detail' newsletter.pk %}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
|
||||
{{ newsletter.title }}
|
||||
</a>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if newsletter.type == 'technology' %}bg-purple-100 text-purple-800
|
||||
{% elif newsletter.type == 'world_news' %}bg-green-100 text-green-800
|
||||
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||
{{ newsletter.get_type_display }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<a href="{% url 'newsletter-detail' newsletter.pk %}"
|
||||
class="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
|
||||
title="{% trans 'View Details' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'newsletter-update' newsletter.pk %}"
|
||||
class="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded"
|
||||
title="{% trans 'Edit' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="{% url 'newsletter-delete' newsletter.pk %}"
|
||||
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
||||
title="{% trans 'Delete' %}">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
{% trans "Updated" %}: {{ newsletter.updated_at|date:"Y-m-d H:i" }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li class="px-4 py-4 text-center text-gray-500">
|
||||
{% trans "No newsletters found." %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% include "links/includes/pagination.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% load static %}
|
||||
{% load markdown_extras %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ newsletter.title }}</title>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
|
||||
<link href="{% static 'css/markdown.css' %}" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="max-w-4xl mx-auto mt-10 p-8 bg-white shadow-md rounded-lg">
|
||||
<!-- Newsletter Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">{{ newsletter.title }}</h1>
|
||||
<div class="mt-2 flex items-center space-x-4">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if newsletter.type == 'technology' %}bg-purple-100 text-purple-800
|
||||
{% elif newsletter.type == 'world_news' %}bg-green-100 text-green-800
|
||||
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||
{{ newsletter.get_type_display }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ newsletter.created_at|date:"Y-m-d H:i" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Newsletter Content -->
|
||||
<div class="prose">
|
||||
{{ newsletter.content|markdown|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,6 +2,7 @@ from django.urls import path, include
|
||||
from . import views
|
||||
from . import page_views
|
||||
from . import search_views
|
||||
from . import newsletter_views
|
||||
|
||||
urlpatterns = [
|
||||
# Regular UI URLs
|
||||
@@ -30,6 +31,18 @@ urlpatterns = [
|
||||
path('fetch-page-info/', page_views.fetch_page_info, name='fetch-page-info'),
|
||||
path('ui/screenshots/', page_views.ScreenshotGalleryView.as_view(), name='screenshot-gallery'),
|
||||
|
||||
# Newsletters
|
||||
path('ui/newsletters/', newsletter_views.NewsletterListView.as_view(), name='newsletter-list'),
|
||||
path('ui/newsletters/new/', newsletter_views.NewsletterCreateView.as_view(), name='newsletter-create'),
|
||||
path('ui/newsletters/<int:pk>/', newsletter_views.NewsletterDetailView.as_view(), name='newsletter-detail'),
|
||||
path('ui/newsletters/<int:pk>/edit/', newsletter_views.NewsletterUpdateView.as_view(), name='newsletter-update'),
|
||||
path('ui/newsletters/<int:pk>/delete/', newsletter_views.NewsletterDeleteView.as_view(), name='newsletter-delete'),
|
||||
|
||||
# Public newsletter URL
|
||||
path('public/newsletters/<int:pk>/',
|
||||
newsletter_views.PublicNewsletterView.as_view(),
|
||||
name='public-newsletter'),
|
||||
|
||||
# Aliases - these should always be last
|
||||
path('<str:alias>/', views.redirect_to_original, name='redirect_to_original'),
|
||||
path('<str:alias>/<str:param>/', views.redirect_to_original, name='redirect_to_original_with_param'),
|
||||
|
||||
Vendored
+35
-46
@@ -706,6 +706,10 @@ video {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-3 {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
@@ -758,11 +762,11 @@ video {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.line-clamp-1 {
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
@@ -946,6 +950,12 @@ video {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.appearance-none {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
@@ -1385,10 +1395,6 @@ video {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-serif {
|
||||
font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
}
|
||||
@@ -1449,6 +1455,10 @@ video {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.leading-6 {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
@@ -1457,6 +1467,10 @@ video {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.leading-tight {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.tracking-normal {
|
||||
letter-spacing: 0em;
|
||||
}
|
||||
@@ -1485,11 +1499,6 @@ video {
|
||||
color: rgb(30 64 175 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-gray-400 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(156 163 175 / var(--tw-text-opacity));
|
||||
@@ -1540,11 +1549,6 @@ video {
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-indigo-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(79 70 229 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.text-purple-500 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(168 85 247 / var(--tw-text-opacity));
|
||||
@@ -1729,6 +1733,11 @@ video {
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-blue-50:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 246 255 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-blue-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
||||
@@ -1754,6 +1763,11 @@ video {
|
||||
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-green-50:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(240 253 244 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-green-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||
@@ -1784,6 +1798,11 @@ video {
|
||||
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-600:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(37 99 235 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-blue-700:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(29 78 216 / var(--tw-text-opacity));
|
||||
@@ -1819,31 +1838,11 @@ video {
|
||||
color: rgb(22 101 52 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-green-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(20 83 45 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-indigo-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(55 48 163 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-indigo-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(49 46 129 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-800:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(153 27 27 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:text-red-900:hover {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(127 29 29 / var(--tw-text-opacity));
|
||||
}
|
||||
|
||||
.hover\:underline:hover {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
@@ -1863,11 +1862,6 @@ video {
|
||||
border-color: rgb(59 130 246 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-indigo-300:focus {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(165 180 252 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.focus\:border-transparent:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -1910,11 +1904,6 @@ video {
|
||||
--tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-indigo-200:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(199 210 254 / var(--tw-ring-opacity));
|
||||
}
|
||||
|
||||
.focus\:ring-red-500:focus {
|
||||
--tw-ring-opacity: 1;
|
||||
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
|
||||
|
||||
Generated
+1329
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: none;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.prose strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: disc;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose ol {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
list-style-type: decimal;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose h1 {
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose h2 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose h3 {
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-style: italic;
|
||||
border-left: 4px solid #e5e7eb;
|
||||
padding-left: 1em;
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2em 0;
|
||||
}
|
||||
.prose table th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.5em;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.prose table td {
|
||||
padding: 0.5em;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
// ...
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/typography'),
|
||||
],
|
||||
}
|
||||
+15
-7
@@ -70,25 +70,33 @@
|
||||
<div id="more-menu-dropdown" class="absolute right-0 mt-2 py-2 w-48 bg-white rounded-md shadow-xl z-20 hidden">
|
||||
<a href="{% url 'search' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{% trans "Advanced Search" %}
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'page-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"></path>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2"/>
|
||||
</svg>
|
||||
{% trans "Pages" %}
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'newsletter-list' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2M3 9l9-6 9 6m-1.5 11.5v-5.3a2 2 0 00-2-2h-13a2 2 0 00-2 2v5.3"/>
|
||||
</svg>
|
||||
{% trans "Newsletters" %}
|
||||
</div>
|
||||
</a>
|
||||
<a href="{% url 'tools' %}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{% trans "Tools" %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user