{{ header.title }}
+ {{ header.description |> md }} +-
+ {{ for link of links }}
+ {{> let i = icon(link.type) }}
+
- + + {{ i?.svg }} + {{ link.text }} + + + {{ /for }} +
diff --git a/.drone.yml b/.drone.yml index aca0df2..77bdc27 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,21 +2,11 @@ kind: pipeline type: docker name: Build & Deploy -clone: - disable: true - steps: - - name: Clone - image: woodpeckerci/plugin-git - settings: - recursive: true - - name: Build site image: denoland/deno commands: - deno task build - depends_on: - - "Clone" - name: Deploy to Deno Deploy image: git.froth.zone/sam/drone-deno-deploy @@ -25,9 +15,9 @@ steps: from_secret: DENO_DEPLOY_TOKEN settings: project: samme - entrypoint: server.ts + entrypoint: serve.ts production: true - include: dist,server.ts,deno.json + include: _site,serve.ts,deno.json import_map: deno.json depends_on: - "Build site" @@ -45,7 +35,7 @@ steps: password: from_secret: PASS target_branch: pages - pages_directory: dist + pages_directory: _site copy_contents: true user_name: Sam Therapy user_email: sam@samtherapy.net diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 64d3d6a..0000000 --- a/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -# EditorConfig is awesome: https://EditorConfig.org - -# top-most EditorConfig file -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index b4b6374..2366ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,88 +1,3 @@ -# Lume generated site -dist/ - -# DS Store -.DS_Store -._.DS_Store -**/.DS_Store -**/._.DS_Store - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# TypeScript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# next.js build output -.next - -# nuxt.js build output -.nuxt - -# vuepress build output -.vuepress/dist - -# Serverless directories -.serverless - -# FuseBox cache -.fusebox/ - -# Snyk -.dccache +_site +deno.lock +_cache \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3c2d050..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "deno.enable": true, - "deno.lint": true, - "deno.unstable": true, - "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "denoland.vscode-deno" - } -} diff --git a/LICENSE b/LICENSE index 017543f..d8491ed 100644 --- a/LICENSE +++ b/LICENSE @@ -1,110 +1,21 @@ -Creative Commons Legal Code CC0 1.0 Universal Official translations of this -legal tool are available +MIT License - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL - SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT - RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" - BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS - DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS - LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE - INFORMATION OR WORKS PROVIDED HEREUNDER. +Copyright (c) 2024 Óscar Otero -Statement of Purpose +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific works -("Commons") that the public can reliably and without fear of later claims of -infringement build upon, modify, incorporate in other works, reuse and -redistribute as freely as possible in any form whatsoever and for any purposes, -including without limitation commercial purposes. These owners may contribute -to the Commons to promote the ideal of a free culture and the further -production of creative, cultural and scientific works, or to gain reputation or -greater distribution for their Work in part through the use and efforts of -others. - -For these and/or other purposes and motivations, and without any expectation of -additional consideration or compensation, the person associating CC0 with a -Work (the "Affirmer"), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and -publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not limited to, -the following: - - the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; moral rights retained by the original author(s) - and/or performer(s); publicity and privacy rights pertaining to a person's - image or likeness depicted in a Work; rights protecting against unfair - competition in regards to a Work, subject to the limitations in paragraph - 4(a), below; rights protecting the extraction, dissemination, use and reuse - of data in a Work; database rights (such as those arising under Directive - 96/9/EC of the European Parliament and of the Council of 11 March 1996 on - the legal protection of databases, and under any national implementation - thereof, including any amended or successor version of such directive); and - other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations - thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, -applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and -unconditionally waives, abandons, and surrenders all of Affirmer's Copyright -and Related Rights and associated claims and causes of action, whether now -known or unknown (including existing as well as future claims and causes of -action), in the Work (i) in all territories worldwide, (ii) for the maximum -duration provided by applicable law or treaty (including future time -extensions), (iii) in any current or future medium and for any number of -copies, and (iv) for any purpose whatsoever, including without limitation -commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes -the Waiver for the benefit of each member of the public at large and to the -detriment of Affirmer's heirs and successors, fully intending that such Waiver -shall not be subject to revocation, rescission, cancellation, termination, or -any other legal or equitable action to disrupt the quiet enjoyment of the Work -by the public as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be -judged legally invalid or ineffective under applicable law, then the Waiver -shall be preserved to the maximum extent permitted taking into account -Affirmer's express Statement of Purpose. In addition, to the extent the Waiver -is so judged Affirmer hereby grants to each affected person a royalty-free, non -transferable, non sublicensable, non exclusive, irrevocable and unconditional -license to exercise Affirmer's Copyright and Related Rights in the Work (i) in -all territories worldwide, (ii) for the maximum duration provided by applicable -law or treaty (including future time extensions), (iii) in any current or -future medium and for any number of copies, and (iv) for any purpose -whatsoever, including without limitation commercial, advertising or promotional -purposes (the "License"). The License shall be deemed effective as of the date -CC0 was applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder of the -License, and in such case Affirmer hereby affirms that he or she will not (i) -exercise any of his or her remaining Copyright and Related Rights in the Work -or (ii) assert any associated claims and causes of action with respect to the -Work, in either case contrary to Affirmer's express Statement of Purpose. - -4. Limitations and Disclaimers. - - No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. Affirmer - offers the Work as-is and makes no representations or warranties of any - kind concerning the Work, express, implied, statutory or otherwise, - including without limitation warranties of title, merchantability, fitness - for a particular purpose, non infringement, or the absence of latent or - other defects, accuracy, or the present or absence of errors, whether or - not discoverable, all to the greatest extent permissible under applicable - law. Affirmer disclaims responsibility for clearing rights of other - persons that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. Further, - Affirmer disclaims responsibility for obtaining any necessary consents, - permissions or other rights required for any use of the Work. Affirmer - understands and acknowledges that Creative Commons is not a party to this - document and has no duty or obligation with respect to this CC0 or use of - the Work. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c008ef --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Simple Me + +[Lume](https://lume.land) theme to create a Linktree alternative. + +## Install as a remote theme + +The **fastest and easiest** way to use this theme is by importing it as a remote +module. It allows to create a blog in seconds and update it at any time just by +changing the version number in the import URL. Just add the following code to +your `_config.ts` file: + +```ts +import lume from "lume/mod.ts"; +import me from "https://deno.land/x/lume_theme_simple_me/mod.ts"; + +const site = lume(); + +site.use(me()); + +export default site; +``` + +## Use it as a base template + +To use this theme as a base template for a more customized site, clone this repo +and edit the [_config.ts](./_config.ts) file. The source files are in the +[src](./src/) folder. diff --git a/_cms.ts b/_cms.ts new file mode 100644 index 0000000..2ca6034 --- /dev/null +++ b/_cms.ts @@ -0,0 +1,82 @@ +import lumeCMS from "lume/cms.ts"; + +const cms = lumeCMS(); + +cms.document( + "home: The profile page", + "src:index.yml", + [ + { + type: "hidden", + name: "layout", + value: "layouts/home.vto", + }, + { + type: "object", + name: "header", + description: "The header of the page", + fields: [ + "title: text", + "description: markdown", + "avatar: file", + ], + }, + { + type: "object", + name: "metas", + description: "Data for the meta tags", + fields: [ + "title: text", + "description: text", + "image: text", + "twitter: text", + "generator: checkbox", + ], + }, + { + name: "links", + type: "object-list", + description: "The list of links.", + fields: [ + { + type: "text", + name: "type", + description: + "The type of link. It uses the icons and colors from https://simpleicons.org/. For example, 'github', 'instagram', etc.", + options: [ + "github", + "instagram", + "linkedin", + "x", + "youtube", + "facebook", + "tiktok", + "patreon", + "paypal", + "mastodon", + "discord", + "spotify", + "opencollective", + "twitch", + ], + }, + "text: text", + "href: text", + ], + }, + { + name: "extra_head", + type: "code", + description: "Extra content to include in the
tag", + }, + { + name: "footer", + type: "markdown", + description: "The footer of the page", + }, + ], +); + +cms.upload("uploads: Uploaded files", "src:*{.jpg,.svg}"); + +export default cms; diff --git a/_config.ts b/_config.ts index c9a347b..035388f 100644 --- a/_config.ts +++ b/_config.ts @@ -1,60 +1,11 @@ -import lume from "lume/mod.ts" - -// Stable plugins -import attributes from "lume/plugins/attributes.ts" -import codeHighlight from "lume/plugins/code_highlight.ts" -import esbuild from "lume/plugins/esbuild.ts" -import jsx from "lume/plugins/jsx_preact.ts" -import katex from "lume/plugins/katex.ts" -import lightningcss from "lume/plugins/lightningcss.ts" -import metas from "lume/plugins/metas.ts" -import minifyHTML from "lume/plugins/minify_html.ts" -import mdx from "lume/plugins/mdx.ts" -import pug from "lume/plugins/pug.ts" -import remark from "lume/plugins/remark.ts" -import sass from "lume/plugins/sass.ts" -import sitemap from "lume/plugins/sitemap.ts" -import sourceMaps from "lume/plugins/source_maps.ts" -import svgo from "lume/plugins/svgo.ts" - -// Experimental plugins - -// Custom plugins -import toml from "./custom/toml/toml.ts" +import lume from "lume/mod.ts"; +import plugins from "./plugins.ts"; const site = lume({ src: "./src", - dest: "./dist", - location: new URL("https://samtherapy.net"), -}) + location: new URL("https://samtherapy.net") +}); -site - .copy("static", ".") - .copy("static/.well-known", ".well-known") - .copy(".domains") - .loadData([".toml"], toml) - .use(attributes()) - .use(codeHighlight()) - .use(katex()) - .use(metas()) - .use(jsx()) - .use(mdx()) - .use(remark()) - .use(pug()) - .use(sitemap()) - .use(svgo()) - .remoteFile( - "_includes/styles/external/nord.min.css", - "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.6.0/build/styles/nord.min.css", - ) - .use(esbuild({ - extensions: [".ts", ".js"], - })) - .use(lightningcss()) - .use(sass()) - .use(minifyHTML()) - .use(sourceMaps({ - sourceContent: true, - })) +site.use(plugins()); -export default site +export default site; diff --git a/custom/toml/toml.ts b/custom/toml/toml.ts deleted file mode 100644 index bc43fd5..0000000 --- a/custom/toml/toml.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { parse } from "std/encoding/toml.ts" - - -export default async function toml(path: string | URL) { - const content = await Deno.readTextFile(path) - return parse(content) -} diff --git a/deno.json b/deno.json index 0eb4ba5..c0b2a0b 100644 --- a/deno.json +++ b/deno.json @@ -1,42 +1,18 @@ { + "imports": { + "lume/": "https://deno.land/x/lume@v2.1.2/" + }, "tasks": { - "lume": "echo \"import 'lume/cli.ts'\" | deno run --unstable -A -", + "lume": "echo \"import 'lume/cli.ts'\" | deno run -A -", "build": "deno task lume", "serve": "deno task lume -s" }, - "lock": false, "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "npm:preact", - "lib": [ - "dom", - "dom.iterable", - "dom.asynciterable", - "deno.ns" + "types": [ + "lume/types.ts" ] }, - "imports": { - "lume/": "https://deno.land/x/lume@v1.16.2/", - "experimental/": "https://raw.githubusercontent.com/lumeland/experimental-plugins/main/", - "std/": "https://deno.land/std/" - }, - "lint": { - "files": { - "exclude": [ - "src/_includes/styles/external/", - "dist/" - ] - } - }, - "fmt": { - "options": { - "semiColons": false - }, - "files": { - "exclude": [ - "src/_includes/styles/external/", - "dist" - ] - } - } + "exclude": [ + "./_site" + ] } diff --git a/mod.ts b/mod.ts new file mode 100644 index 0000000..4b258eb --- /dev/null +++ b/mod.ts @@ -0,0 +1,25 @@ +import plugins from "./plugins.ts"; + +import "lume/types.ts"; + +export default function () { + return (site: Lume.Site) => { + // Configure the site + site.use(plugins()); + + // Add remote files + const files = [ + "_includes/css/header.css", + "_includes/css/link.css", + "_includes/layouts/base.vto", + "index.yml", + "styles.css", + "favicon.svg", + "avatar.jpg", + ]; + + for (const file of files) { + site.remoteFile(file, import.meta.resolve(`./src/${file}`)); + } + }; +} diff --git a/plugins.ts b/plugins.ts new file mode 100644 index 0000000..57c1309 --- /dev/null +++ b/plugins.ts @@ -0,0 +1,53 @@ +import "lume/types.ts"; +import favicon from "lume/plugins/favicon.ts" +import postcss from "lume/plugins/postcss.ts"; +import transformImages from "lume/plugins/transform_images.ts"; +import metas from "lume/plugins/metas.ts"; +import minifyHTML from "lume/plugins/minify_html.ts"; +import svgo from "lume/plugins/svgo.ts"; +import basePath from "lume/plugins/base_path.ts"; +import * as si from "npm:simple-icons@11.9.0"; +import type { SimpleIcon } from "npm:simple-icons@11.9.0"; +import Color from "https://colorjs.io/dist/color.js"; + +const icons = Object.values(si) as SimpleIcon[]; + +/** Configure the site */ +export default function () { + return (site: Lume.Site) => { + site.use(postcss()) + .use(favicon()) + .use(metas()) + .use(svgo()) + .use(basePath()) + .mergeKey("extra_head", "stringArray") + .use(transformImages()) + .copy("static", ".") + .use(minifyHTML({ + extensions: [".css", ".html", ".js"] + })); + + site.data("icon", (slug?: string) => { + if (!slug) return; + return icons.find((icon) => icon.slug === slug); + }); + + site.data("textColor", (hex: string) => { + const color = new Color(`#${hex}`); + const onWhite = Math.abs(color.contrastWCAG21("white")); + const onBlack = Math.abs(color.contrastWCAG21("black")); + return (onWhite + 0.5) > onBlack ? "white" : "black"; + }); + + site.data("transformImages", { + resize: [300, 300], + format: "webp", + }); + + // Basic CSS Design System + site.remoteFile( + "_includes/css/ds.css", + "https://unpkg.com/@lumeland/ds@0.5.1/ds.css", + ); + }; +} diff --git a/serve.ts b/serve.ts new file mode 100644 index 0000000..7b9dd80 --- /dev/null +++ b/serve.ts @@ -0,0 +1,24 @@ +import Server from "lume/core/server.ts"; +import expires from "lume/middlewares/expires.ts" + +const server = new Server({ + port: 8000, + root: `${Deno.cwd()}/_site`, +}); + +// Set Access-Control-Allow-Origin header to allow all origins +server.use(async (request, next) => { + // Here you can modify the request before being passed to next middlewares + const response = await next(request); + + response.headers.set('Access-Control-Allow-Origin', '*') + // Here you can modify the response before being returned to the previous middleware + return response; +}); + + +server.use(expires()) + +server.start(); + +console.log("Listening on http://localhost:8000"); \ No newline at end of file diff --git a/server.ts b/server.ts deleted file mode 100644 index b901721..0000000 --- a/server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Server from "lume/core/server.ts" -import expires from "lume/middlewares/expires.ts" -import not_found from "lume/middlewares/not_found.ts" - -const port = 8000 - -const server = new Server({ - port: port, - root: `${Deno.cwd()}/dist`, -}) - -server.use(async (request, next) => { - // Here you can modify the request before being passed to next middlewares - const response = await next(request); - - response.headers.set('Access-Control-Allow-Origin', '*') - // Here you can modify the response before being returned to the previous middleware - return response; -}); - -server.use(expires()) - -server.use(not_found({ - root: `${Deno.cwd()}/dist`, - page404: "404.html", -})) - -server.start() - -console.log(`Listening on http://localhost:${port}`) diff --git a/src/.domains b/src/.domains deleted file mode 100644 index ffa0b4f..0000000 --- a/src/.domains +++ /dev/null @@ -1 +0,0 @@ -samtherapy.xyz \ No newline at end of file diff --git a/src/404.mdx b/src/404.mdx deleted file mode 100644 index c5d7cac..0000000 --- a/src/404.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Well, how did I get here? -subtitle: 404 - Not found -layout: layouts/base.pug -url: /404.html ---- - -#### Where does that [highway](/) go to? - -```js -window.location.replace("/"); -``` diff --git a/src/_data.toml b/src/_data.toml deleted file mode 100644 index d19f6e7..0000000 --- a/src/_data.toml +++ /dev/null @@ -1,24 +0,0 @@ -# Site metas from Lume metas plugin -[metas] -site = "Sam's site" -lang = "en" -description = "The website of a random fishe" -icon = "img/favicon.png" -keywords = ["nothing"] -robots = true -generator = true - -[[menu.left]] -name = "About" -url = "/about" - -[[menu.left]] -name = "Contact" -url = "/contact" - -[[menu.right]] -name = "Site" -url = "/" - -[mergedKeys] -metas = "object" diff --git a/src/_includes/css/header.css b/src/_includes/css/header.css new file mode 100644 index 0000000..5ce0413 --- /dev/null +++ b/src/_includes/css/header.css @@ -0,0 +1,36 @@ +.header { + font: var(--font-body); + margin-bottom: min(5vh, 100px); + color: var(--color-text); + + p { + margin: 0; + text-wrap: balance; + + + p { + margin-top: .5em; + } + } +} + +.header-avatar { + border-radius: 50%; + aspect-ratio: 1; + object-fit: cover; + object-position: center center; + width: 200px; + max-width: 50vw; +} + +.header-title { + font: var(--font-title); + letter-spacing: var(--font-title-spacing); + margin: .5em 0 0; + color: var(--color-base); +} + +.header-theme { + position: absolute; + top: 1rem; + right: 1.5rem; +} \ No newline at end of file diff --git a/src/_includes/css/link.css b/src/_includes/css/link.css new file mode 100644 index 0000000..d4ee140 --- /dev/null +++ b/src/_includes/css/link.css @@ -0,0 +1,35 @@ +.link-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + row-gap: 10px; + + .button { + display: flex; + font: var(--font-body-bold); + transition: transform 200ms; + border: solid 1px #00000022; + + &:hover { + transform: scale(1.05); + box-shadow: 0 2px 10px -8px #0009; + } + } + .button:not(.is-primary) { + background: var(--bg-color); + color: var(--text-color); + } + + svg { + width: 20px; + height: 20px; + fill: currentColor; + } +} + +[data-theme="dark"] { + .link-list .button { + border: solid 1px #FFFFFF16; + } +} \ No newline at end of file diff --git a/src/_includes/layouts/base.pug b/src/_includes/layouts/base.pug deleted file mode 100644 index 36d45e1..0000000 --- a/src/_includes/layouts/base.pug +++ /dev/null @@ -1,14 +0,0 @@ -doctype html -html(lang=metas.lang) - head - title= title - include meta.pug - include nav.pug - body - header - h1= title - h2= subtitle - main - | !{content} - footer - include footer.html diff --git a/src/_includes/layouts/base.vto b/src/_includes/layouts/base.vto new file mode 100644 index 0000000..21aebc8 --- /dev/null +++ b/src/_includes/layouts/base.vto @@ -0,0 +1,60 @@ + + + + + + +