Add testimonials
This commit is contained in:
parent
06e943d478
commit
40223a12b5
7 changed files with 318 additions and 101 deletions
69
cmd/test_provider/test_provider.go
Normal file
69
cmd/test_provider/test_provider.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// this package provides a wrapper to test completion provider implementations.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.chandlerswift.com/chandlerswift/svs-services-server/completion/openrouter"
|
||||
)
|
||||
|
||||
var provider = openrouter.OpenRouterProvider{
|
||||
Token: os.Getenv("OPENROUTER_API_KEY"),
|
||||
Model: "openai/gpt-oss-120b",
|
||||
}
|
||||
|
||||
func main() {
|
||||
prompt := "Generate a sample weather report."
|
||||
|
||||
// {
|
||||
// name: 'weather',
|
||||
// strict: true,
|
||||
// schema: {
|
||||
// type: 'object',
|
||||
// properties: {
|
||||
// location: {
|
||||
// type: 'string',
|
||||
// description: 'City or location name',
|
||||
// },
|
||||
// temperature: {
|
||||
// type: 'number',
|
||||
// description: 'Temperature in Celsius',
|
||||
// },
|
||||
// conditions: {
|
||||
// type: 'string',
|
||||
// description: 'Weather conditions description',
|
||||
// },
|
||||
// },
|
||||
// required: ['location', 'temperature', 'conditions'],
|
||||
// additionalProperties: false,
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
schema := map[string]any{
|
||||
"name": "weather",
|
||||
"strict": true,
|
||||
"schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"location": map[string]any{
|
||||
"type": "string",
|
||||
"description": "City or location name",
|
||||
},
|
||||
"temperature": map[string]any{
|
||||
"type": "number",
|
||||
"description": "Temperature in Celsius",
|
||||
},
|
||||
"conditions": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Weather conditions description",
|
||||
},
|
||||
},
|
||||
"required": []string{"location", "temperature", "conditions"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
}
|
||||
result, err := provider.Complete(context.TODO(), prompt, schema)
|
||||
fmt.Println(err, result)
|
||||
}
|
||||
|
|
@ -71,7 +71,8 @@ func (p LlamaServerProvider) Health() (err error) {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p LlamaServerProvider) Complete(ctx context.Context, prompt string) (response string, err error) {
|
||||
func (p LlamaServerProvider) Complete(ctx context.Context, prompt string, schema any) (response string, err error) {
|
||||
// TODO: schema enforcement
|
||||
req := Request{
|
||||
Messages: []Message{
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,16 +22,13 @@ type Message struct {
|
|||
}
|
||||
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stop json.RawMessage `json:"stop,omitempty"` // string or []string; keep flexible
|
||||
Provider struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Provider struct {
|
||||
Sort string `json:"sort,omitempty"`
|
||||
} `json:"provider,omitempty"`
|
||||
|
||||
ResponseFormat any `json:"response_format,omitempty"` // keep flexible
|
||||
Plugins []map[string]string `json:"plugins,omitempty"`
|
||||
}
|
||||
|
||||
type ChatCompletionResponse struct {
|
||||
|
|
@ -46,7 +43,7 @@ type ChatCompletionResponse struct {
|
|||
} `json:"choices"`
|
||||
}
|
||||
|
||||
func (p OpenRouterProvider) Complete(ctx context.Context, prompt string) (string, error) {
|
||||
func (p OpenRouterProvider) Complete(ctx context.Context, prompt string, schema any) (string, error) {
|
||||
req := ChatCompletionRequest{
|
||||
Model: p.Model,
|
||||
Messages: []Message{
|
||||
|
|
@ -62,6 +59,18 @@ func (p OpenRouterProvider) Complete(ctx context.Context, prompt string) (string
|
|||
},
|
||||
}
|
||||
|
||||
if schema != nil {
|
||||
req.ResponseFormat = map[string]any{
|
||||
"type": "json_schema",
|
||||
"json_schema": schema,
|
||||
}
|
||||
req.Plugins = []map[string]string{
|
||||
{
|
||||
"id": "response-healing",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := http.Client{Timeout: 10 * time.Second}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ package completion
|
|||
import "context"
|
||||
|
||||
type CompletionProvider interface {
|
||||
Complete(ctx context.Context, prompt string) (string, error)
|
||||
Complete(ctx context.Context, prompt string, schema any) (string, error)
|
||||
}
|
||||
|
|
|
|||
20
main.go
20
main.go
|
|
@ -26,8 +26,24 @@ var notFoundTemplate string
|
|||
//go:embed headshots/*
|
||||
var headshots embed.FS
|
||||
|
||||
var tmpl = template.Must(template.New("index").Parse(indexTemplate))
|
||||
var notFoundTmpl = template.Must(template.New("404").Parse(notFoundTemplate))
|
||||
// Used for testimonial slider
|
||||
var funcMap = template.FuncMap{
|
||||
"add": func(a, b int) int {
|
||||
return a + b
|
||||
},
|
||||
"sub": func(a, b int) int {
|
||||
return a - b
|
||||
},
|
||||
"mod": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return a % b
|
||||
},
|
||||
}
|
||||
|
||||
var tmpl = template.Must(template.New("index").Funcs(funcMap).Parse(indexTemplate))
|
||||
var notFoundTmpl = template.Must(template.New("404").Funcs(funcMap).Parse(notFoundTemplate))
|
||||
|
||||
var completionProviders map[string]completion.CompletionProvider = map[string]completion.CompletionProvider{
|
||||
"openrouter": openrouter.OpenRouterProvider{
|
||||
|
|
|
|||
82
site/site.go
82
site/site.go
|
|
@ -93,7 +93,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
prompt = strings.TrimSpace(prompt)
|
||||
prompt = whitespace.ReplaceAllString(prompt, " ")
|
||||
|
||||
site.Occupation, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, rawservice))
|
||||
site.Occupation, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, rawservice), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing service: %w", err)
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
e.g. for a web design company, something like "Custom websites cooked to order".
|
||||
Do not include quotes.
|
||||
`)
|
||||
site.Tagline, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation))
|
||||
site.Tagline, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating tagline: %w", err)
|
||||
}
|
||||
|
|
@ -129,7 +129,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
Markup should be in HTML, including paragraph breaks.
|
||||
Do not wrap in any additional markup.
|
||||
`)
|
||||
whatWeDo, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation))
|
||||
whatWeDo, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
|
||||
site.WhatWeDo = template.HTML(whatWeDo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating what we do: %w", err)
|
||||
|
|
@ -150,7 +150,7 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
Emphasize the relevance of all the above information to SVS's line of work.
|
||||
Do not shoehorn all the above information; only include each piece if relevant.
|
||||
`)
|
||||
bio, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation))
|
||||
bio, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating Chandler bio: %w", err)
|
||||
}
|
||||
|
|
@ -171,11 +171,11 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
Emphasize the relevance of all the above information to SVS's line of work.
|
||||
Do not include all the above information; only mention each piece if relevant.
|
||||
`)
|
||||
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation))
|
||||
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating Isaac bio: %w", err)
|
||||
return nil, fmt.Errorf("generating Eric bio: %w", err)
|
||||
}
|
||||
site.EricBio = template.HTML(fmt.Sprintf("Eric is a seasoned professional in the %v industry, known for his expertise and dedication.", site.Occupation))
|
||||
site.EricBio = template.HTML(bio)
|
||||
|
||||
prompt = norm(`
|
||||
Generate a short, satirical/irreverent bio (2-4 sentences) for Isaac Swift, a co-founder of SVS, a %v company.
|
||||
|
|
@ -195,31 +195,63 @@ func GenerateSiteData(rawservice string, cp completion.CompletionProvider) (site
|
|||
Emphasize the relevance of all the above information to SVS's line of work.
|
||||
Do not include all the above information; only mention each piece if relevant.
|
||||
`)
|
||||
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation))
|
||||
bio, err = cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating Isaac bio: %w", err)
|
||||
}
|
||||
site.IsaacBio = template.HTML(bio)
|
||||
|
||||
|
||||
|
||||
site.Testimonials = []Testimonial{
|
||||
{
|
||||
Name: "Barack Obama",
|
||||
Position: "Poolhall acquaintance",
|
||||
Quote: "After the fiasco of the ACA Website, I know how important it is to get a website right on the first try, and boy do these gentlemen deliver!",
|
||||
},
|
||||
{
|
||||
Name: "Ada Lovelace",
|
||||
Position: "Pioneer of Computing",
|
||||
Quote: "The elegance and functionality of the websites created by SVS Web Design are truly ahead of their time.",
|
||||
},
|
||||
{
|
||||
Name: "Steve Jobs",
|
||||
Position: "Visionary Entrepreneur",
|
||||
Quote: "Design is not just what it looks like and feels like. Design is how it works. SVS Web Design understands this principle deeply.",
|
||||
prompt = norm(`
|
||||
Generate three satirical testimonials from satisfied clients of SVS, a %v company.
|
||||
Testimonials should be from well-known figures, possibly historical (say, a former US President).
|
||||
When possible, the person quoted should have some tenuous connection to the services provided by SVS.
|
||||
This may be heavily ironic, say Abe Lincoln giving a testimonial for SVS's web design services, or Helen Keller praising their audio engineering.
|
||||
Other possible sources include George Washington, Thomas Edison, Nikola Tesla, Marie Curie, Albert Einstein, Winston Churchill, Franklin D. Roosevelt, John F. Kennedy, Ronald Reagan, Margaret Thatcher, and Martin Luther King Jr.
|
||||
Each testimonial should be 1-2 sentences long, and include an author and their position.
|
||||
For example, if SVS did web design, a testimonial might be from "Barack Obama", a "Poolhall acquaintance":
|
||||
"After the fiasco of the ACA Website, I know how important it is to get a website right on the first try,
|
||||
and boy do these gentlemen deliver!"
|
||||
Do not include quotes around the testimonial text.
|
||||
Return the testimonials in JSON format, as an array of objects with "name", "position", and "quote" properties.
|
||||
`)
|
||||
schema := map[string]any{
|
||||
"name": "testimonials",
|
||||
"strict": true,
|
||||
"schema": map[string]any{
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"Name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Name of the person giving the testimonial",
|
||||
},
|
||||
"Position": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Position or title of the person giving the testimonial",
|
||||
},
|
||||
"Quote": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Testimonial text",
|
||||
},
|
||||
},
|
||||
"required": []string{"Name", "Position", "Quote"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
raw_testimonials, err := cp.Complete(context.TODO(), fmt.Sprintf(prompt, site.Occupation), schema)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating testimonials: %w", err)
|
||||
}
|
||||
fmt.Println("Raw testimonials: %s\n", raw_testimonials)
|
||||
err = json.Unmarshal([]byte(raw_testimonials), &site.Testimonials)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unmarshaling testimonials: %w", err)
|
||||
}
|
||||
|
||||
site.CurrentYear = time.Now().Year()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
|
|
@ -54,13 +58,14 @@
|
|||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(2.2rem, 3vw, 3.4rem);
|
||||
margin: 0;
|
||||
font-size: clamp(2.2rem, 5vw, 5rem);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
header h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: clamp(2.2rem, 3vw, 3rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -107,6 +112,12 @@
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-lead {
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#what-we-do {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
|
@ -179,23 +190,31 @@
|
|||
.slides {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 180px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@property --auto {
|
||||
syntax: '<number>';
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
|
||||
.slides-track {
|
||||
display: flex;
|
||||
width: calc(100% * var(--count));
|
||||
transform: translateX(calc(-33.3333% * var(--current, 0)));
|
||||
transition: transform 320ms ease;
|
||||
animation: auto-pan calc(var(--count) * 6s) infinite linear;
|
||||
}
|
||||
|
||||
.slide-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transition: opacity 250ms ease, transform 250ms ease;
|
||||
flex: 0 0 calc(100% / var(--count));
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slide p {
|
||||
|
|
@ -207,11 +226,31 @@
|
|||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
.control-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #c7cedc;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, background 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.control-dot:hover {
|
||||
transform: scale(1.1);
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.slides:has(.slide-toggle:checked) .slides-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.control-arrow {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
|
|
@ -224,16 +263,83 @@
|
|||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
.control-arrow:hover {
|
||||
background: #eef2ff;
|
||||
}
|
||||
|
||||
.arrow-sets {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arrow-set {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.arrow-set:first-of-type {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes auto-pan {
|
||||
0% {
|
||||
--auto: 0;
|
||||
}
|
||||
100% {
|
||||
--auto: calc(var(--count) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Active state wiring via :has so dots/arrows track selection. */
|
||||
.slides {
|
||||
--current: var(--auto);
|
||||
}
|
||||
|
||||
/* Default to first dot when nothing is picked. */
|
||||
.controls .control-dot:first-of-type {
|
||||
background: #eef2ff;
|
||||
border-color: #c7cedc;
|
||||
}
|
||||
|
||||
.arrow-set .control-arrow {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
{{- range $index, $_ := .Testimonials }}
|
||||
.slides:has(#t-{{$index}}:checked) {
|
||||
--current: {{$index}};
|
||||
}
|
||||
|
||||
.slides:has(#t-{{$index}}:checked) .controls .control-dot[for="t-{{$index}}"] {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.slides:has(#t-{{$index}}:checked) .arrow-set.arrows-{{$index}} {
|
||||
display: flex;
|
||||
}
|
||||
{{- end }}
|
||||
|
||||
footer {
|
||||
background: #0b1224;
|
||||
color: #f1f5f9;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer .footer-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -274,7 +380,7 @@
|
|||
<section id="about">
|
||||
<div class="container">
|
||||
<h2>About Us</h2>
|
||||
<p class="muted" style="text-align:center; margin:0 auto; max-width:700px;">Three builders, one vision: ship reliable, human-centered software and services.</p>
|
||||
<p class="muted section-lead">Three builders, one vision: ship reliable, human-centered software and services.</p>
|
||||
<div class="about-grid">
|
||||
<div class="profile-card">
|
||||
<img src="headshots/chandlerswift.jpg"
|
||||
|
|
@ -303,18 +409,33 @@
|
|||
<section id="testimonials">
|
||||
<div class="container">
|
||||
<h2>What Our Clients Say</h2>
|
||||
<div class="testimonial-shell" data-slider>
|
||||
<div class="slides">
|
||||
{{range $index, $testimonial := .Testimonials}}
|
||||
<div class="slide {{if eq $index 0}}active{{end}}">
|
||||
<p>"{{$testimonial.Quote}}"</p>
|
||||
<strong>— {{$testimonial.Name}}, {{$testimonial.Position}}</strong>
|
||||
{{- $count := len .Testimonials -}}
|
||||
<div class="testimonial-shell">
|
||||
<div class="slides" style="--count: {{$count}};">
|
||||
<div class="slides-track">
|
||||
{{range $index, $testimonial := .Testimonials}}
|
||||
<input type="radio" name="testimonial" id="t-{{$index}}" class="slide-toggle" {{if eq $index 0}}checked{{end}}>
|
||||
<div class="slide">
|
||||
<p>"{{$testimonial.Quote}}"</p>
|
||||
<strong>— {{$testimonial.Name}}, {{$testimonial.Position}}</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="arrow-sets">
|
||||
{{range $index, $_ := .Testimonials}}
|
||||
{{- $prev := mod (add $index (sub $count 1)) $count -}}
|
||||
{{- $next := mod (add $index 1) $count -}}
|
||||
<div class="arrow-set arrows-{{$index}}">
|
||||
<label class="control-arrow" for="t-{{$prev}}" aria-label="Previous testimonial">←</label>
|
||||
<label class="control-arrow" for="t-{{$next}}" aria-label="Next testimonial">→</label>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="controls">
|
||||
{{range $index, $_ := .Testimonials}}
|
||||
<label class="control-dot" for="t-{{$index}}" aria-label="Show testimonial {{$index}}"></label>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="control-btn" data-prev aria-label="Previous testimonial">←</button>
|
||||
<button class="control-btn" data-next aria-label="Next testimonial">→</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -325,43 +446,12 @@
|
|||
<p>
|
||||
<b>Swift, Villnow & Swift Industries</b><br>
|
||||
{{.Occupation}} Division<br>
|
||||
<a href="tel:2182105180" style="color:#cbd5e1;">(218) 210-5180</a><br>
|
||||
<a href="mailto:sales@svsindustries.org" style="color:#cbd5e1;">sales@svsindustries.org</a>
|
||||
<a href="tel:2182105180">(218) 210-5180</a><br>
|
||||
<a href="mailto:sales@svsindustries.org">sales@svsindustries.org</a>
|
||||
</p>
|
||||
<p>© {{.CurrentYear}} Swift, Villnow & Swift Industries. All rights reserved.</p>
|
||||
<p>© {{.CurrentYear}} Swift, Villnow & Swift Industries<br>All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const slider = document.querySelector('[data-slider]');
|
||||
if (!slider) return;
|
||||
const slides = Array.from(slider.querySelectorAll('.slide'));
|
||||
const prev = slider.querySelector('[data-prev]');
|
||||
const next = slider.querySelector('[data-next]');
|
||||
let index = slides.findIndex(s => s.classList.contains('active'));
|
||||
|
||||
function show(i) {
|
||||
slides.forEach((s, idx) => {
|
||||
s.classList.toggle('active', idx === i);
|
||||
});
|
||||
}
|
||||
|
||||
function go(delta) {
|
||||
index = (index + delta + slides.length) % slides.length;
|
||||
show(index);
|
||||
}
|
||||
|
||||
prev?.addEventListener('click', () => go(-1));
|
||||
next?.addEventListener('click', () => go(1));
|
||||
|
||||
let timer = setInterval(() => go(1), 6500);
|
||||
slider.addEventListener('mouseenter', () => clearInterval(timer));
|
||||
slider.addEventListener('mouseleave', () => {
|
||||
timer = setInterval(() => go(1), 6500);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue