cmd/golink: provide user documentation

Add a new /.help URL which contains a first pass at user docs, covering
naming rules for short links, using go templates, and basic API access.
Also add client side validation for short name and a link to help page.

Change-Id: I4b66793248eba17e567c4f422ee197617f74ee24
This commit is contained in:
Will Norris
2022-06-21 09:25:14 -07:00
parent ed35fa8624
commit abf84df32e
9 changed files with 679 additions and 44 deletions
+15 -6
View File
@@ -47,7 +47,7 @@ var stats struct {
//go:embed link-snapshot.json
var lastSnapshot []byte
//go:embed *.html static
//go:embed static tmpl/*.html
var embeddedFS embed.FS
// db stores short links.
@@ -89,6 +89,7 @@ func main() {
http.HandleFunc("/", serveGo)
http.HandleFunc("/.export", serveExport)
http.HandleFunc("/.help", serveHelp)
http.Handle("/_/export", http.RedirectHandler("/.export", http.StatusMovedPermanently))
http.Handle("/.static/", http.StripPrefix("/.", http.FileServer(http.FS(embeddedFS))))
@@ -120,23 +121,27 @@ func main() {
}
}
// homeCreate is the template used by the http://go/ index page where you can
// homeTmpl is the template used by the http://go/ index page where you can
// create or edit links.
var homeCreate *template.Template
var homeTmpl *template.Template
// helpTmpl is the template used by the http://go/.help page
var helpTmpl *template.Template
type visitData struct {
Short string
NumClicks int
}
// homeData is the data used by the homeCreate template.
// homeData is the data used by the homeTmpl template.
type homeData struct {
Short string
Clicks []visitData
}
func init() {
homeCreate = template.Must(template.ParseFS(embeddedFS, "home.html"))
homeTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/home.html"))
helpTmpl = template.Must(template.ParseFS(embeddedFS, "tmpl/base.html", "tmpl/help.html"))
}
// initStats initializes the in-memory stats counter with counts from db.
@@ -199,12 +204,16 @@ func serveHome(w http.ResponseWriter, short string) {
return clicks[i].Short < clicks[j].Short
})
homeCreate.Execute(w, homeData{
homeTmpl.Execute(w, homeData{
Short: html.EscapeString(short),
Clicks: clicks,
})
}
func serveHelp(w http.ResponseWriter, r *http.Request) {
helpTmpl.Execute(w, nil)
}
func serveGo(w http.ResponseWriter, r *http.Request) {
if r.RequestURI == "/" {
switch r.Method {
+1
View File
@@ -6,6 +6,7 @@
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.2",
"@tailwindcss/typography": "^0.5.2",
"tailwindcss": "^3.0.24"
}
}
+460 -8
View File
@@ -671,6 +671,422 @@ select {
}
}
.prose {
color: #333;
max-width: 65ch;
}
.prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-lead);
font-size: 1.25em;
line-height: 1.6;
margin-top: 1.2em;
margin-bottom: 1.2em;
}
.prose :where(a):not(:where([class~="not-prose"] *)) {
color: #3182ce;
text-decoration: underline;
font-weight: 500;
}
.prose :where(a):not(:where([class~="not-prose"] *)):hover {
color: #2c5282;
}
.prose :where(strong):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-bold);
font-weight: 600;
}
.prose :where(ol):not(:where([class~="not-prose"] *)) {
list-style-type: decimal;
padding-left: 1.625em;
}
.prose :where(ol[type="A"]):not(:where([class~="not-prose"] *)) {
list-style-type: upper-alpha;
}
.prose :where(ol[type="a"]):not(:where([class~="not-prose"] *)) {
list-style-type: lower-alpha;
}
.prose :where(ol[type="A" s]):not(:where([class~="not-prose"] *)) {
list-style-type: upper-alpha;
}
.prose :where(ol[type="a" s]):not(:where([class~="not-prose"] *)) {
list-style-type: lower-alpha;
}
.prose :where(ol[type="I"]):not(:where([class~="not-prose"] *)) {
list-style-type: upper-roman;
}
.prose :where(ol[type="i"]):not(:where([class~="not-prose"] *)) {
list-style-type: lower-roman;
}
.prose :where(ol[type="I" s]):not(:where([class~="not-prose"] *)) {
list-style-type: upper-roman;
}
.prose :where(ol[type="i" s]):not(:where([class~="not-prose"] *)) {
list-style-type: lower-roman;
}
.prose :where(ol[type="1"]):not(:where([class~="not-prose"] *)) {
list-style-type: decimal;
}
.prose :where(ul):not(:where([class~="not-prose"] *)) {
list-style-type: disc;
padding-left: 1.625em;
}
.prose :where(ol > li):not(:where([class~="not-prose"] *))::marker {
font-weight: 400;
color: var(--tw-prose-counters);
}
.prose :where(ul > li):not(:where([class~="not-prose"] *))::marker {
color: var(--tw-prose-bullets);
}
.prose :where(hr):not(:where([class~="not-prose"] *)) {
border-color: var(--tw-prose-hr);
border-top-width: 1px;
margin-top: 3em;
margin-bottom: 3em;
}
.prose :where(blockquote):not(:where([class~="not-prose"] *)) {
font-weight: 500;
font-style: italic;
color: var(--tw-prose-quotes);
border-left-width: 0.25rem;
border-left-color: var(--tw-prose-quote-borders);
quotes: "\201C""\201D""\2018""\2019";
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-left: 1em;
}
.prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *))::before {
content: open-quote;
}
.prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *))::after {
content: close-quote;
}
.prose :where(h1):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 800;
font-size: 2.25em;
margin-top: 0;
margin-bottom: 0.8888889em;
line-height: 1.1111111;
}
.prose :where(h1 strong):not(:where([class~="not-prose"] *)) {
font-weight: 900;
}
.prose :where(h2):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 700;
font-size: 1.5em;
margin-top: 2em;
margin-bottom: 1em;
line-height: 1.3333333;
}
.prose :where(h2 strong):not(:where([class~="not-prose"] *)) {
font-weight: 800;
}
.prose :where(h3):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 600;
font-size: 1.25em;
margin-top: 1.6em;
margin-bottom: 0.6em;
line-height: 1.6;
}
.prose :where(h3 strong):not(:where([class~="not-prose"] *)) {
font-weight: 700;
}
.prose :where(h4):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.5;
}
.prose :where(h4 strong):not(:where([class~="not-prose"] *)) {
font-weight: 700;
}
.prose :where(figure > *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(figcaption):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-captions);
font-size: 0.875em;
line-height: 1.4285714;
margin-top: 0.8571429em;
}
.prose :where(code):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-code);
font-weight: 600;
font-size: 0.875em;
}
.prose :where(code):not(:where([class~="not-prose"] *))::before {
content: ;
}
.prose :where(code):not(:where([class~="not-prose"] *))::after {
content: ;
}
.prose :where(a code):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-links);
}
.prose :where(pre):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-pre-code);
background-color: var(--tw-prose-pre-bg);
overflow-x: auto;
font-weight: 400;
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;
}
.prose :where(pre code):not(:where([class~="not-prose"] *)) {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: inherit;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose :where(pre code):not(:where([class~="not-prose"] *))::before {
content: none;
}
.prose :where(pre code):not(:where([class~="not-prose"] *))::after {
content: none;
}
.prose :where(table):not(:where([class~="not-prose"] *)) {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.7142857;
}
.prose :where(thead):not(:where([class~="not-prose"] *)) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-th-borders);
}
.prose :where(thead th):not(:where([class~="not-prose"] *)) {
color: var(--tw-prose-headings);
font-weight: 600;
vertical-align: bottom;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose :where(tbody tr):not(:where([class~="not-prose"] *)) {
border-bottom-width: 1px;
border-bottom-color: var(--tw-prose-td-borders);
}
.prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)) {
border-bottom-width: 0;
}
.prose :where(tbody td):not(:where([class~="not-prose"] *)) {
vertical-align: baseline;
padding-top: 0.5714286em;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose {
--tw-prose-body: #374151;
--tw-prose-headings: #111827;
--tw-prose-lead: #4b5563;
--tw-prose-links: #111827;
--tw-prose-bold: #111827;
--tw-prose-counters: #6b7280;
--tw-prose-bullets: #d1d5db;
--tw-prose-hr: #e5e7eb;
--tw-prose-quotes: #111827;
--tw-prose-quote-borders: #e5e7eb;
--tw-prose-captions: #6b7280;
--tw-prose-code: #111827;
--tw-prose-pre-code: #e5e7eb;
--tw-prose-pre-bg: #1f2937;
--tw-prose-th-borders: #d1d5db;
--tw-prose-td-borders: #e5e7eb;
--tw-prose-invert-body: #d1d5db;
--tw-prose-invert-headings: #fff;
--tw-prose-invert-lead: #9ca3af;
--tw-prose-invert-links: #fff;
--tw-prose-invert-bold: #fff;
--tw-prose-invert-counters: #9ca3af;
--tw-prose-invert-bullets: #4b5563;
--tw-prose-invert-hr: #374151;
--tw-prose-invert-quotes: #f3f4f6;
--tw-prose-invert-quote-borders: #374151;
--tw-prose-invert-captions: #9ca3af;
--tw-prose-invert-code: #fff;
--tw-prose-invert-pre-code: #d1d5db;
--tw-prose-invert-pre-bg: rgb(0 0 0 / 50%);
--tw-prose-invert-th-borders: #4b5563;
--tw-prose-invert-td-borders: #374151;
font-size: 1rem;
line-height: 1.75;
}
.prose :where(p):not(:where([class~="not-prose"] *)) {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.prose :where(img):not(:where([class~="not-prose"] *)) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(video):not(:where([class~="not-prose"] *)) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(figure):not(:where([class~="not-prose"] *)) {
margin-top: 2em;
margin-bottom: 2em;
}
.prose :where(h2 code):not(:where([class~="not-prose"] *)) {
font-size: 0.875em;
}
.prose :where(h3 code):not(:where([class~="not-prose"] *)) {
font-size: 0.9em;
}
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose :where(ol > li):not(:where([class~="not-prose"] *)) {
padding-left: 0.375em;
}
.prose :where(ul > li):not(:where([class~="not-prose"] *)) {
padding-left: 0.375em;
}
.prose > :where(ul > li p):not(:where([class~="not-prose"] *)) {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose > :where(ul > li > *:first-child):not(:where([class~="not-prose"] *)) {
margin-top: 1.25em;
}
.prose > :where(ul > li > *:last-child):not(:where([class~="not-prose"] *)) {
margin-bottom: 1.25em;
}
.prose > :where(ol > li > *:first-child):not(:where([class~="not-prose"] *)) {
margin-top: 1.25em;
}
.prose > :where(ol > li > *:last-child):not(:where([class~="not-prose"] *)) {
margin-bottom: 1.25em;
}
.prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose :where(hr + *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
}
.prose :where(h2 + *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
}
.prose :where(h3 + *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
}
.prose :where(h4 + *):not(:where([class~="not-prose"] *)) {
margin-top: 0;
}
.prose :where(thead th:first-child):not(:where([class~="not-prose"] *)) {
padding-left: 0;
}
.prose :where(thead th:last-child):not(:where([class~="not-prose"] *)) {
padding-right: 0;
}
.prose :where(tbody td:first-child):not(:where([class~="not-prose"] *)) {
padding-left: 0;
}
.prose :where(tbody td:last-child):not(:where([class~="not-prose"] *)) {
padding-right: 0;
}
.prose > :where(:first-child):not(:where([class~="not-prose"] *)) {
margin-top: 0;
}
.prose > :where(:last-child):not(:where([class~="not-prose"] *)) {
margin-bottom: 0;
}
.absolute {
position: absolute;
}
.m-2 {
margin: 0.5rem;
}
@@ -680,6 +1096,11 @@ select {
margin-right: auto;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -693,6 +1114,10 @@ select {
margin-top: 1.5rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.block {
display: block;
}
@@ -713,10 +1138,18 @@ select {
min-height: 100vh;
}
.max-w-4xl {
max-width: 56rem;
}
.max-w-full {
max-width: 100%;
}
.max-w-5xl {
max-width: 64rem;
}
.flex-1 {
flex: 1 1 0%;
}
@@ -759,14 +1192,14 @@ select {
border-bottom-width: 1px;
}
.border-r-0 {
border-right-width: 0px;
}
.border-t {
border-top-width: 1px;
}
.border-r-0 {
border-right-width: 0px;
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(230 228 226 / var(--tw-border-opacity));
@@ -823,6 +1256,10 @@ select {
padding-bottom: 0.25rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.pt-6 {
padding-top: 1.5rem;
}
@@ -831,10 +1268,6 @@ select {
padding-right: 2rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.text-left {
text-align: left;
}
@@ -862,6 +1295,11 @@ select {
line-height: 1rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.font-bold {
font-weight: 700;
}
@@ -889,6 +1327,20 @@ select {
color: rgb(159 153 149 / var(--tw-text-opacity));
}
.text-blue-200 {
color: rgba(43, 123, 185, 0.15);
}
.text-blue-500 {
--tw-text-opacity: 1;
color: rgb(74 125 221 / var(--tw-text-opacity));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(77 120 200 / var(--tw-text-opacity));
}
.placeholder\:text-gray-400::-moz-placeholder {
--tw-text-opacity: 1;
color: rgb(182 176 173 / var(--tw-text-opacity));
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+16 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
content: ["./*.html"],
content: ["./tmpl/*.html"],
theme: {
colors: {
blue: {
@@ -27,8 +27,23 @@ module.exports = {
white: '#fff',
current: 'currentColor',
},
extend: {
typography: {
DEFAULT: {
css: {
'code::before': {
'content': '',
},
'code::after': {
'content': '',
},
},
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}
+4 -29
View File
@@ -1,4 +1,5 @@
<html>
<!doctype html>
<html lang="en">
<head>
<title>go/</title>
<link rel="stylesheet" href="/.static/base.css">
@@ -7,39 +8,13 @@
<body class="flex flex-col min-h-screen">
<div class="bg-gray-100 border-b border-gray-200 pt-4 pb-2 mb-6">
<header class="container mx-auto px-4">
<h1 class="text-2xl font-bold pb-1">go/</h1>
<h1 class="text-2xl font-bold pb-1"><a href="/">go/</a></h1>
A private shortlink service for your tailnet
</header>
</div>
<main class="container mx-auto px-4 flex-1">
<h2 class="text-xl font-bold pb-2">Create a new link</h2>
<form method="POST" action="/" class="flex flex-wrap">
<div class="flex">
<label for=short class="flex my-2 px-2 items-center bg-gray-100 border border-r-0 border-gray-300 rounded-l-md text-gray-700">http://go/</label>
<input id=short name=short required type=text size=15 placeholder="shortname" value="{{.Short}}"
class="p-2 my-2 rounded-r-md border-gray-300 placeholder:text-gray-400">
<span class="flex m-2 items-center">&rarr;</span>
</div>
<input name=long required type=text size=40 placeholder="https://destination-url"{{if .Short}} autofocus{{end}} class="p-2 my-2 max-w-full rounded-md border-gray-300 placeholder:text-gray-400">
<button type=submit class="py-2 px-4 m-2 rounded-md bg-blue-500 border-blue-500 text-white hover:bg-blue-600 hover:border-blue-600">Create</button>
</form>
<h2 class="text-xl font-bold pt-6 pb-2">Popular Links</h2>
<table class="table-auto ">
<thead class="border-b border-gray-200 uppercase text-xs text-gray-500 text-left">
<tr>
<th class="p-2">Link</th>
<th class="p-2">Clicks</th>
</tr>
</thead>
<tbody>
{{range .Clicks}}
<tr class="hover:bg-gray-100 border-b border-gray-200"><td><a class="block p-2 pr-8 hover:text-blue-500 hover:underline" href="http://go/{{.Short}}">go/{{.Short}}</a></td><td class="p-2">{{.NumClicks}}</td></tr>
{{end}}
</tbody>
</table>
{{ block "main" .}}{{ end }}
</main>
<footer class="bg-gray-100 border-t border-gray-200 pt-2 pb-2 mt-6">
<div class="container mx-auto px-4 leading-6 text-right">
+129
View File
@@ -0,0 +1,129 @@
{{ define "main" }}
<article class="prose max-w-5xl">
<p>
go links provide short, memorable links for the websites you and your team use most.
<h2>Creating go links</h2>
<p>
All go links have a <strong>short name</strong> and a <strong>destination link</strong> that the go link points to.
Some notes on short names:
<ul>
<li>names must start with a letter or number
<li>names may contain letters, numbers, hyphens, and periods
<li>names are <strong>not</strong> case-sensitive (go/foo is the same as go/FOO)
<li>hyphens are ignored when resolving links (go/meetingnotes is the same as go/meeting-notes)
</ul>
<p>
In simple cases, the destination link is an absolute URL, such as <strong>https://www.google.com/</strong>.
<p>
<img width=737 height=62 class="mx-4" src="/.static/images/create-link.png">
<h2>Resolving links</h2>
<p>
When logged in to your Tailscale network, go links can be entered directly into any browser or command line utility such as curl.
You do not need any additional browser extensions.
<p>
Any additional path provided after the short name will be added to the end of the destination link.
For example, if <strong>go/who</strong> goes to your company directory at <strong>http://directory/</strong>,
then <strong>go/who/amelie</strong> will go to <strong>http://directory/amelie</strong>.
<p>
<a href="#advanced">Advanced destination links</a> allow you to further customize this behavior.
<h2 id="advanced">Advanced destination links</h2>
<p>
To have more control over how go links are resolved, destination links can use <a href="https://pkg.go.dev/text/template">Go template syntax</a>.
Templates are provided a data structure with the following fields:
<ul>
<li><code>.Path</code> is the remaining path value after the short name (without a leading slash). For the link <strong>go/who/amelie</strong>,
the value of <code>.Path</code> is <code>amelie</code>.
<li><code>.Now</code> is a <a href="https://pkg.go.dev/time#Time">time.Time</a> value representing the current date and time.
Use <a href="https://pkg.go.dev/time#Time.Format">Time.Format</a> to output date or time in desired format.
</ul>
Templates also have access to the following template functions:
<ul>
<li><code>PathEscape</code> is the <a href="https://pkg.go.dev/net/url#PathEscape">url.PathEscape</a> function for escaping values inside a URL path.
<li><code>QueryEscape</code> is the <a href="https://pkg.go.dev/net/url#QueryEscape">url.QueryEscape</a> function for escaping values inside a URL query.
</ul>
<p>
The most common use of advanced destination links is to put the additional path in a custom location in the destination link.
For example, you might set the destination for <strong>go/search</strong> to:
<pre>{{`https://www.google.com/{{if .Path}}search?q={{QueryEscape .Path}}{{end}}`}}</pre>
When a user visits <strong>go/search</strong> with no additional path, they will be directed to <a href="https://www.google.com/">https://www.google.com/</a>.
If they include an additional path like <strong>go/search/pangolins</strong>, they will be directed to <a href="https://www.google.com/search?q=pangolins">https://www.google.com/search?q=pangolins</a>.
<h3>Examples</h3>
<table>
<tr>
<td>Include path in query</td>
<td>go/search</td>
<td>{{`https://cloudsearch.google.com/{{if .Path}}cloudsearch/search?q={{QueryEscape .Path}}{{end}}`}}</td>
</tr>
<tr>
<td>Include path in destination path</td>
<td>go/slack</td>
<td>{{`https://company.slack.com/{{if .Path}}channels/{{PathEscape .Path}}{{end}}`}}</td>
</tr>
<tr>
<td>Include path in hostname</td>
<td>go/varz</td>
<td>{{`http://{{if .Path}}{{.Path}}{{else}}host{{end}}.example/debug/varz`}}</td>
</tr>
<tr>
<td>Include today's date in wiki page</td>
<td>go/today</td>
<td>{{`http://wiki/{{.Now.Format "01-02-2006"}}`}}</td>
</tr>
</table>
<h2 id="api">Application Programming Interface (API)</h2>
<p>
There is no formal API, but many endpoints lend themselves to programmatic access.
<p>
Include a "+" after a link to get information about a link without resolving it:
<pre>{{`$ curl go/search+
{
"Short": "search",
"Long": "https://cloudsearch.google.com/{{if .Path}}cloudsearch/search?q={{QueryEscape .Path}}{{end}}",
"Created": "2022-06-08T04:27:32.829906577Z",
"LastEdit": "2022-06-13T04:42:08.396702416Z",
"Owner": "amelie@company.com",
"Clicks": 8
}`}}
</pre>
<p>
Visit <a href="http://go/.export">go/.export</a> to export all saved links and their metadata in <a href="https://jsonlines.org/">JSON Lines format</a>.
This is useful to create data snapshots that can be restored later.
<pre>{{`$ curl go/.export
{"Short":"go","Long":"http://go","Created":"2022-05-31T13:04:44.741457796-07:00","LastEdit":"2022-05-31T13:04:44.741457796-07:00","Owner":"amelie@example.com","Clicks":1}
{"Short":"slack","Long":"https://company.slack.com/{{if .Path}}channels/{{PathEscape .Path}}{{end}}","Created":"2022-06-17T18:05:43.562948451Z","LastEdit":"2022-06-17T18:06:35.811398Z","Owner":"amelie@example.com","Clicks":4}`}}
</pre>
<p>
Create a new link by sending a POST request with a <code>short</code> and <code>long</code> value:
<pre>{{`$ curl -d short=cs -d long=https://cs.github.com/ go
{"Short":"cs","Long":"https://cs.github.com/","Created":"2022-06-03T22:15:29.993978392Z","LastEdit":"2022-06-03T22:15:29.993978392Z","Owner":"amelie@example.com"}`}}
</pre>
</article>
{{ end }}
+30
View File
@@ -0,0 +1,30 @@
{{ define "main" }}
<h2 class="text-xl font-bold pb-2">Create a new link</h2>
<form method="POST" action="/" class="flex flex-wrap">
<div class="flex">
<label for=short class="flex my-2 px-2 items-center bg-gray-100 border border-r-0 border-gray-300 rounded-l-md text-gray-700">http://go/</label>
<input id=short name=short required type=text size=15 placeholder="shortname" value="{{.Short}}" pattern="\w[\w\-\.]*" title="Must start with letter or number; may contain letters, numbers, dashes, and periods."
class="p-2 my-2 rounded-r-md border-gray-300 placeholder:text-gray-400">
<span class="flex m-2 items-center">&rarr;</span>
</div>
<input name=long required type=text size=40 placeholder="https://destination-url"{{if .Short}} autofocus{{end}} class="p-2 my-2 mr-2 max-w-full rounded-md border-gray-300 placeholder:text-gray-400">
<button type=submit class="py-2 px-4 my-2 rounded-md bg-blue-500 border-blue-500 text-white hover:bg-blue-600 hover:border-blue-600">Create</button>
</form>
<p class="text-sm text-gray-500"><a class="text-blue-600 hover:underline" href="/.help">Help and advanced options</a></p>
<h2 class="text-xl font-bold pt-6 pb-2">Popular Links</h2>
<table class="table-auto ">
<thead class="border-b border-gray-200 uppercase text-xs text-gray-500 text-left">
<tr>
<th class="p-2">Link</th>
<th class="p-2">Clicks</th>
</tr>
</thead>
<tbody>
{{range .Clicks}}
<tr class="hover:bg-gray-100 border-b border-gray-200"><td><a class="block p-2 pr-8 hover:text-blue-500 hover:underline" href="http://go/{{.Short}}">go/{{.Short}}</a></td><td class="p-2">{{.NumClicks}}</td></tr>
{{end}}
</tbody>
</table>
{{ end }}
+24
View File
@@ -30,6 +30,15 @@
dependencies:
mini-svg-data-uri "^1.2.3"
"@tailwindcss/typography@^0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.2.tgz#24b069dab24d7a2467d01aca0dd432cb4b29f0ee"
integrity sha512-coq8DBABRPFcVhVIk6IbKyyHUt7YTEC/C992tatFB+yEx5WGBQrCgsSFjxHUr8AWXphWckadVJbominEduYBqw==
dependencies:
lodash.castarray "^4.4.0"
lodash.isplainobject "^4.0.6"
lodash.merge "^4.6.2"
acorn-node@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
@@ -220,6 +229,21 @@ lilconfig@^2.0.5:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.5.tgz#19e57fd06ccc3848fd1891655b5a447092225b25"
integrity sha512-xaYmXZtTHPAw5m+xLN8ab9C+3a8YmV3asNSPOATITbtwrfbwaLJj8h66H1WMIpALCkqsIzK3h7oQ+PdX+LQ9Eg==
lodash.castarray@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
merge2@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"